Website: netOrk v0.28.0 and real screenshots #2
@@ -195,10 +195,16 @@ against the dark background:
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
||||||
<img src="/screenshots/device-list.png" alt="Device inventory" className="w-full" />
|
<img src="/screenshots/devices.webp" alt="Device inventory" className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Only real screenshots of the running application.** No JSX mockups or
|
||||||
|
drawn imitations of the UI. They come from an anonymized demo copy of a real
|
||||||
|
installation and are taken with `scripts/screenshots/capture.py` (see
|
||||||
|
`scripts/demo/README.md`), published as WebP in `public/screenshots/`.
|
||||||
|
`Screenshot` in `src/pages/Home.tsx` is the frame.
|
||||||
|
|
||||||
Optionally add a browser chrome header above the image:
|
Optionally add a browser chrome header above the image:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -206,7 +212,7 @@ Optionally add a browser chrome header above the image:
|
|||||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||||
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||||
<span className="ml-4 text-xs text-slate-500 font-mono">netork.local</span>
|
<span className="ml-4 text-xs text-slate-500 font-mono">netork / devices</span>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -124,45 +124,21 @@ Each badge uses the `Driver / Integration Badge` component from DESIGN.md.
|
|||||||
|
|
||||||
### Section 5 — Screenshot Walkthrough (alternating)
|
### Section 5 — Screenshot Walkthrough (alternating)
|
||||||
|
|
||||||
**Purpose:** Show the UI concretely. Three alternating image + text rows.
|
**Purpose:** Show the UI concretely. Six alternating image + text rows, each a
|
||||||
|
real screenshot from `scripts/screenshots/shots.py`. Copy lives in
|
||||||
|
`home.screenshot1`–`screenshot6` in `src/i18n/translations.ts`.
|
||||||
|
|
||||||
**Row 1 — Left text, right screenshot**
|
| Row | Heading | Screenshot |
|
||||||
- Heading: `Device detail at a glance`
|
|---|---|---|
|
||||||
- Copy: `Hostname, IP, vendor, OS version, last poll time, and active
|
| 1 | Device detail at a glance | `device-detail` — an access point, Networking → Interfaces |
|
||||||
warnings on one card. Tabbed detail view for interfaces, LLDP neighbors,
|
| 2 | Intent-based VLAN and SSID management | `vlans` — VLAN list by site |
|
||||||
ARP table, VLAN membership, packages, services, and scheduled jobs.`
|
| 3 | A security assessment for every device | `device-security` — a server, Security → Assessment |
|
||||||
- Screenshot: DeviceDetailPage
|
| 4 | One triage queue, decisions that hold | `vulnerabilities` — the triage queue |
|
||||||
|
| 5 | Dashboards you actually build | `dashboard` — the home dashboard |
|
||||||
|
| 6 | Service checks every minute | `service-checks` — Network → Service Checks |
|
||||||
|
|
||||||
**Row 2 — Right text, left screenshot**
|
Text sits left on odd rows and right on even rows; on mobile the text always
|
||||||
- Heading: `Intent-based VLAN and SSID management`
|
comes first.
|
||||||
- Copy: `Define VLAN names and SSID settings once. netOrk compares them
|
|
||||||
against every polled device and pushes corrections automatically via
|
|
||||||
UCI (OpenWRT) or the device's native API.`
|
|
||||||
- Screenshot: VlansPage or WirelessPage
|
|
||||||
|
|
||||||
**Row 3 — Left text, right screenshot**
|
|
||||||
- Heading: `Security visibility per device`
|
|
||||||
- Copy: `Wazuh agent status, CVE counts by severity, and recent alerts
|
|
||||||
— all linked to the device record. One-click agent install if the
|
|
||||||
agent is missing. Graylog syslog forwarding status with auto-fix.`
|
|
||||||
- Screenshot: SecurityTab inside DeviceDetailPage
|
|
||||||
|
|
||||||
**Row 4 — Right text, left screenshot**
|
|
||||||
- Heading: `Configuration backup and versioning`
|
|
||||||
- Copy: `Every poll captures a config snapshot into a local Git
|
|
||||||
repository. The Config tab shows the full snapshot history, a
|
|
||||||
side-by-side diff between any two points in time, and — for
|
|
||||||
OPNsense — a Restore button. Unauthorized changes show up as a
|
|
||||||
device warning.`
|
|
||||||
- Screenshot: ConfigTab inside DeviceDetailPage
|
|
||||||
|
|
||||||
**Row 5 — Left text, right screenshot**
|
|
||||||
- Heading: `Dashboards you actually build`
|
|
||||||
- Copy: `Pick from 13 widgets and arrange them on a WYSIWYG grid — no
|
|
||||||
more fixed layout. Share a dashboard with a colleague, let them
|
|
||||||
subscribe to your live version or clone it into their own, and pin
|
|
||||||
favorites to the main menu.`
|
|
||||||
- Screenshot: DashboardDetailPage (edit mode)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -170,7 +146,7 @@ Each badge uses the `Driver / Integration Badge` component from DESIGN.md.
|
|||||||
|
|
||||||
**Purpose:** Hook for organizations evaluating netOrk in a NIS2 context.
|
**Purpose:** Hook for organizations evaluating netOrk in a NIS2 context.
|
||||||
|
|
||||||
**Layout:** Left column — label + Art. 21 mapping list. Right column — mock compliance overview UI.
|
**Layout:** Left column — label + Art. 21 mapping list. Right column — screenshot of the audit log (`audit-log`), the evidence the list refers to.
|
||||||
|
|
||||||
**Label (eyebrow):** `NIS2 · Art. 21` (sky-500, uppercase, tracking-widest)
|
**Label (eyebrow):** `NIS2 · Art. 21` (sky-500, uppercase, tracking-widest)
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -50,6 +50,11 @@ SKIP_TABLES = {
|
|||||||
}
|
}
|
||||||
TEXT_TYPES = {"text", "character varying", "jsonb", "json", "inet", "cidr", "macaddr", "ARRAY"}
|
TEXT_TYPES = {"text", "character varying", "jsonb", "json", "inet", "cidr", "macaddr", "ARRAY"}
|
||||||
|
|
||||||
|
# Only these count as internal addresses to move; Python's is_private also
|
||||||
|
# covers 0.0.0.0/8 and friends, which in practice are version numbers.
|
||||||
|
PRIVATE_NETS = [ipaddress.IPv4Network(n) for n in
|
||||||
|
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")]
|
||||||
|
|
||||||
# Well-known public resolvers stay as they are; they say nothing about anyone.
|
# Well-known public resolvers stay as they are; they say nothing about anyone.
|
||||||
KEEP_PUBLIC = {"1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4", "9.9.9.9", "149.112.112.112"}
|
KEEP_PUBLIC = {"1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4", "9.9.9.9", "149.112.112.112"}
|
||||||
|
|
||||||
@@ -57,6 +62,8 @@ IPV4 = re.compile(r"(?<![\d.])((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5
|
|||||||
MAC = re.compile(r"(?<![0-9A-Fa-f:-])([0-9A-Fa-f]{2}([:-])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2})(?![0-9A-Fa-f:-])")
|
MAC = re.compile(r"(?<![0-9A-Fa-f:-])([0-9A-Fa-f]{2}([:-])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2})(?![0-9A-Fa-f:-])")
|
||||||
MAC_DOT = re.compile(r"(?<![0-9A-Fa-f.])([0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4})(?![0-9A-Fa-f.])")
|
MAC_DOT = re.compile(r"(?<![0-9A-Fa-f.])([0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4})(?![0-9A-Fa-f.])")
|
||||||
IPV6 = re.compile(r"(?<![0-9A-Fa-f:])((?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4})(?![0-9A-Fa-f:])")
|
IPV6 = re.compile(r"(?<![0-9A-Fa-f:])((?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4})(?![0-9A-Fa-f:])")
|
||||||
|
# Reverse zones and PTR names: 8.22.172.in-addr.arpa is 172.22.8.0/24.
|
||||||
|
REVERSE = re.compile(r"(?<![\d.])((?:\d{1,3}\.){1,4})in-addr\.arpa", re.I)
|
||||||
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}")
|
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}")
|
||||||
|
|
||||||
|
|
||||||
@@ -69,8 +76,13 @@ class Mapper:
|
|||||||
# {"home.corp.example.com": "hq.example.demo", "corp.example.com": "example.demo"}
|
# {"home.corp.example.com": "hq.example.demo", "corp.example.com": "example.demo"}
|
||||||
self.domains: dict[str, str] = cfg.get("domains", {})
|
self.domains: dict[str, str] = cfg.get("domains", {})
|
||||||
self.prefix16 = dict(cfg.get("ipv4_prefix16", {}))
|
self.prefix16 = dict(cfg.get("ipv4_prefix16", {}))
|
||||||
self.pool16 = iter(cfg.get("ipv4_pool16", [f"10.{n}" for n in range(20, 250, 10)]))
|
taken = set(self.prefix16.values())
|
||||||
|
pool = cfg.get("ipv4_pool16") or (
|
||||||
|
[f"10.{n}" for n in range(20, 256, 10)] + [f"10.{n}" for n in range(256) if n % 10]
|
||||||
|
+ [f"172.{n}" for n in range(16, 32)])
|
||||||
|
self.pool16 = iter(p for p in pool if p not in taken)
|
||||||
self.public: dict[str, str] = {}
|
self.public: dict[str, str] = {}
|
||||||
|
self.public_used: set[str] = set()
|
||||||
# Public-looking dotted quads are only mapped once they were seen as an
|
# Public-looking dotted quads are only mapped once they were seen as an
|
||||||
# address (see collect_public); "kernel 6.8.0.45" is a version, not a host.
|
# address (see collect_public); "kernel 6.8.0.45" is a version, not a host.
|
||||||
self.known_public: set[str] = set(cfg.get("public_ips", []))
|
self.known_public: set[str] = set(cfg.get("public_ips", []))
|
||||||
@@ -84,6 +96,8 @@ class Mapper:
|
|||||||
alt = "|".join(re.escape(k) for k in sorted(names, key=len, reverse=True))
|
alt = "|".join(re.escape(k) for k in sorted(names, key=len, reverse=True))
|
||||||
# A name is a whole token: not glued to letters, digits, '-' or '_'.
|
# A name is a whole token: not glued to letters, digits, '-' or '_'.
|
||||||
self.names_re = re.compile(rf"(?<![\w-])({alt})(?![\w-])")
|
self.names_re = re.compile(rf"(?<![\w-])({alt})(?![\w-])")
|
||||||
|
# Plain substrings, for names glued into identifiers (HOME_OFFICE_MGMT_NET).
|
||||||
|
self.substrings: dict[str, str] = cfg.get("substrings", {})
|
||||||
self.domain_re = None
|
self.domain_re = None
|
||||||
if self.domains:
|
if self.domains:
|
||||||
alt = "|".join(re.escape(d) for d in sorted(self.domains, key=len, reverse=True))
|
alt = "|".join(re.escape(d) for d in sorted(self.domains, key=len, reverse=True))
|
||||||
@@ -96,16 +110,28 @@ class Mapper:
|
|||||||
if ip in KEEP_PUBLIC or a.is_loopback or a.is_multicast or a.is_unspecified \
|
if ip in KEEP_PUBLIC or a.is_loopback or a.is_multicast or a.is_unspecified \
|
||||||
or a.is_link_local or ip.startswith("255.") or a.is_reserved:
|
or a.is_link_local or ip.startswith("255.") or a.is_reserved:
|
||||||
return ip
|
return ip
|
||||||
if a.is_private:
|
if any(a in net for net in PRIVATE_NETS):
|
||||||
p = ".".join(ip.split(".")[:2])
|
p = ".".join(ip.split(".")[:2])
|
||||||
if p not in self.prefix16:
|
if p not in self.prefix16:
|
||||||
self.prefix16[p] = next(self.pool16)
|
self.prefix16[p] = next(self.pool16)
|
||||||
return self.prefix16[p] + "." + ".".join(ip.split(".")[2:])
|
return self.prefix16[p] + "." + ".".join(ip.split(".")[2:])
|
||||||
|
if not a.is_global:
|
||||||
|
return ip # 0.x, 192.0.0.x, benchmark ... : versions more often than hosts
|
||||||
if ip not in self.known_public:
|
if ip not in self.known_public:
|
||||||
self.unmapped_public[ip] = self.unmapped_public.get(ip, 0) + 1
|
self.unmapped_public[ip] = self.unmapped_public.get(ip, 0) + 1
|
||||||
return ip
|
return ip
|
||||||
if ip not in self.public:
|
if ip not in self.public:
|
||||||
self.public[ip] = next(self.public_pool)
|
fake = next(self.public_pool, None)
|
||||||
|
probe = 0
|
||||||
|
while fake is None or fake in self.public_used:
|
||||||
|
# Documentation ranges exhausted (CrowdSec alone brings tens of
|
||||||
|
# thousands of attacker addresses): hash into the non-routable
|
||||||
|
# benchmark range 198.18.0.0/15, probing on collision.
|
||||||
|
n = int(h(f"{ip}/{probe}", 8), 16) % (2 ** 17)
|
||||||
|
fake = f"198.{18 + (n >> 16)}.{(n >> 8) & 255}.{n & 255}"
|
||||||
|
probe += 1
|
||||||
|
self.public_used.add(fake)
|
||||||
|
self.public[ip] = fake
|
||||||
return self.public[ip]
|
return self.public[ip]
|
||||||
|
|
||||||
def mac(self, m: str) -> str:
|
def mac(self, m: str) -> str:
|
||||||
@@ -148,8 +174,19 @@ class Mapper:
|
|||||||
return e
|
return e
|
||||||
return f"user-{h(e.lower(), 6)}@{DEMO_DOMAIN}"
|
return f"user-{h(e.lower(), 6)}@{DEMO_DOMAIN}"
|
||||||
|
|
||||||
|
def reverse(self, m: re.Match) -> str:
|
||||||
|
octets = m.group(1).rstrip(".").split(".")[::-1] # forward order
|
||||||
|
if len(octets) < 2 or any(int(o) > 255 for o in octets):
|
||||||
|
return m.group(0)
|
||||||
|
padded = octets + ["0"] * (4 - len(octets))
|
||||||
|
mapped = self.ipv4(".".join(padded)).split(".")[:len(octets)]
|
||||||
|
return ".".join(mapped[::-1]) + ".in-addr.arpa"
|
||||||
|
|
||||||
# -- whole strings -------------------------------------------------------
|
# -- whole strings -------------------------------------------------------
|
||||||
def text(self, s: str) -> str:
|
def text(self, s: str) -> str:
|
||||||
|
s = SECRET_JSON.sub(lambda m: m.group(0) if m.group(1) in SECRET_JSON_KEEP
|
||||||
|
else f'"{m.group(1)}"{m.group(2)}""', s)
|
||||||
|
s = REVERSE.sub(self.reverse, s)
|
||||||
s = EMAIL.sub(self.email, s)
|
s = EMAIL.sub(self.email, s)
|
||||||
if self.domain_re:
|
if self.domain_re:
|
||||||
s = self.domain_re.sub(lambda m: m.group(1) + self.domains[m.group(2).lower()], s)
|
s = self.domain_re.sub(lambda m: m.group(1) + self.domains[m.group(2).lower()], s)
|
||||||
@@ -159,20 +196,29 @@ class Mapper:
|
|||||||
s = IPV4.sub(lambda m: self.ipv4(m.group(1)), s)
|
s = IPV4.sub(lambda m: self.ipv4(m.group(1)), s)
|
||||||
if self.names_re:
|
if self.names_re:
|
||||||
s = self.names_re.sub(lambda m: self.names[m.group(1)], s)
|
s = self.names_re.sub(lambda m: self.names[m.group(1)], s)
|
||||||
|
for old, new in self.substrings.items():
|
||||||
|
s = s.replace(old, new)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
# Cheap server-side prefilter: only rows that could contain something to map.
|
# Cheap server-side prefilter: only rows that could contain something to map.
|
||||||
def prefilter(cfg: dict) -> str:
|
def prefilter(cfg: dict) -> str:
|
||||||
parts = [r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", r"[0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-]",
|
parts = [r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", r"[0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-]",
|
||||||
r"[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.", r"[0-9A-Fa-f]{1,4}::?[0-9A-Fa-f]{1,4}:", "@"]
|
r"[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.", r"[0-9A-Fa-f]{1,4}::?[0-9A-Fa-f]{1,4}:", "@",
|
||||||
for k in [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {})]:
|
r"in-addr\.arpa", r"(key|psk|passphrase|password|secret|token)\"\s*:"]
|
||||||
|
for k in [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
|
||||||
|
*cfg.get("substrings", {})]:
|
||||||
parts.append(re.escape(k))
|
parts.append(re.escape(k))
|
||||||
return "|".join(parts)
|
return "|".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# Columns emptied wherever they occur, found by name so a new table is covered.
|
# Columns emptied wherever they occur, found by name so a new table is covered.
|
||||||
SECRET_COLUMN = re.compile(r"(password|secret|private_key|api_key|apikey|token)", re.I)
|
SECRET_COLUMN = re.compile(r"(password|secret|private_key|api_key|apikey|token|passphrase|psk|ft_key|wpa_key)", re.I)
|
||||||
|
# The same inside JSON and text: device snapshots carry Wi-Fi keys and the like.
|
||||||
|
SECRET_JSON = re.compile(
|
||||||
|
r'"((?:[A-Za-z0-9_]*_)?(?:key|psk|passphrase|password|passwd|secret|token|private_key|ft_key|sae_password))"'
|
||||||
|
r'(\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||||
|
SECRET_JSON_KEEP = {"public_key", "entry_key", "key_type", "is_secret", "ssh_key_id"}
|
||||||
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
|
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
|
||||||
|
|
||||||
# Whole tables that only hold secrets or personal delivery data.
|
# Whole tables that only hold secrets or personal delivery data.
|
||||||
@@ -313,6 +359,8 @@ async def reset_users(con, cfg: dict, dry: bool) -> None:
|
|||||||
"UPDATE users SET username = $2, email = $3, hashed_password = $4, "
|
"UPDATE users SET username = $2, email = $3, hashed_password = $4, "
|
||||||
"totp_secret = NULL, totp_enabled = false, is_active = false WHERE id = $1",
|
"totp_secret = NULL, totp_enabled = false, is_active = false WHERE id = $1",
|
||||||
u["id"], f"operator{n}", f"operator{n}@{DEMO_DOMAIN}", hash_password(os.urandom(16).hex()))
|
u["id"], f"operator{n}", f"operator{n}@{DEMO_DOMAIN}", hash_password(os.urandom(16).hex()))
|
||||||
|
# TOTP secrets are gone, so a role that demands MFA would lock everyone out.
|
||||||
|
await con.execute("UPDATE roles SET require_mfa = false")
|
||||||
role = await con.fetchval("SELECT id FROM roles WHERE lower(name) IN ('administrator', 'admin') LIMIT 1")
|
role = await con.fetchval("SELECT id FROM roles WHERE lower(name) IN ('administrator', 'admin') LIMIT 1")
|
||||||
if role:
|
if role:
|
||||||
await con.execute("UPDATE users SET role_id = $1, is_superuser = true WHERE username = 'netork'", role)
|
await con.execute("UPDATE users SET role_id = $1, is_superuser = true WHERE username = 'netork'", role)
|
||||||
@@ -320,18 +368,26 @@ async def reset_users(con, cfg: dict, dry: bool) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def leak_report(con, cfg: dict, originals: list[str]) -> int:
|
async def leak_report(con, cfg: dict, originals: list[str]) -> int:
|
||||||
needles = [n for n in originals if len(n) >= 4]
|
# Names are matched as written (FAMILY is a VLAN, "family" a JSON key);
|
||||||
if not needles:
|
# leak_terms and domains in any case.
|
||||||
|
names = [n for n in [*cfg.get("hostnames", {}), *cfg.get("terms", {}), *cfg.get("substrings", {})]
|
||||||
|
if len(n) >= 4]
|
||||||
|
loose = [n for n in [*cfg.get("domains", {}), *cfg.get("leak_terms", [])] if len(n) >= 4]
|
||||||
|
# Postgres has no inline (?i:...), so spell case-insensitivity out: [mM][aA]...
|
||||||
|
def anycase(t: str) -> str:
|
||||||
|
return "".join(f"[{c.lower()}{c.upper()}]" if c.isalpha() else re.escape(c) for c in t)
|
||||||
|
parts = [re.escape(n) for n in names] + [anycase(n) for n in loose]
|
||||||
|
if not parts:
|
||||||
return 0
|
return 0
|
||||||
pat = "|".join(re.escape(n) for n in needles)
|
pat = "|".join(parts)
|
||||||
found = 0
|
found = 0
|
||||||
for t, c, _ in await columns(con):
|
for t, c, _ in await columns(con):
|
||||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~* $1', pat)
|
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~ $1', pat)
|
||||||
if n:
|
if n:
|
||||||
found += n
|
found += n
|
||||||
sample = await con.fetchval(
|
sample = await con.fetchval(
|
||||||
f'SELECT substring("{c}"::text from $1) FROM "{t}" WHERE "{c}"::text ~* $1 LIMIT 1',
|
f'SELECT substring("{c}"::text from $2) FROM "{t}" WHERE "{c}"::text ~ $1 LIMIT 1',
|
||||||
f"(?i)(.{{0,30}}(?:{pat}).{{0,30}})")
|
pat, f"(.{{0,30}}(?:{pat}).{{0,30}})")
|
||||||
print(f" LEAK {t}.{c}: {n} rows, e.g. …{sample}…")
|
print(f" LEAK {t}.{c}: {n} rows, e.g. …{sample}…")
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,24 @@ case "${1:-}" in
|
|||||||
got=$(docker exec "$DB" psql -U netork -tA -c "SELECT version_num FROM alembic_version")
|
got=$(docker exec "$DB" psql -U netork -tA -c "SELECT version_num FROM alembic_version")
|
||||||
want=$(cd "$SRC" && PATH="$VENV/bin:$PATH" alembic heads 2>/dev/null | awk '{print $1}')
|
want=$(cd "$SRC" && PATH="$VENV/bin:$PATH" alembic heads 2>/dev/null | awk '{print $1}')
|
||||||
echo "dump schema: $got $VERSION head: $want"
|
echo "dump schema: $got $VERSION head: $want"
|
||||||
[ "$got" = "$want" ] || echo "WARNING: schema differs from $VERSION; screens may not match the release."
|
# Anonymize first: it empties every secret, so a downgrade that would
|
||||||
|
# have to decrypt something (with a key we do not have) finds nothing.
|
||||||
"$VENV/bin/python" "$HERE/anonymize.py"
|
"$VENV/bin/python" "$HERE/anonymize.py"
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
# The production instance runs a newer build. Walk the copy back to the
|
||||||
|
# release with the newer code's own downgrade migrations.
|
||||||
|
NEWER="${NETORK_NEWER_REF:-origin/main}"
|
||||||
|
echo "migrating the copy from $got back to $want with $NEWER's migrations"
|
||||||
|
rm -rf "$DEMO/src-newer"; mkdir -p "$DEMO/src-newer"
|
||||||
|
git -C "$NETORK_REPO" archive "$NEWER" | tar -x -C "$DEMO/src-newer"
|
||||||
|
# Rows the older schema cannot hold: CrowdSec blocklist alerts whose scope
|
||||||
|
# is a list name, longer than the column they go back into.
|
||||||
|
docker exec "$DB" psql -U netork -q -c \
|
||||||
|
"DELETE FROM crowdsec_alerts WHERE length(source_scope) > 32" 2>/dev/null || true
|
||||||
|
(cd "$DEMO/src-newer" && PATH="$VENV/bin:$PATH" \
|
||||||
|
DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork" alembic downgrade "$want")
|
||||||
|
"$VENV/bin/python" "$HERE/anonymize.py" --report-only
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
start)
|
start)
|
||||||
ensure_src; ensure_db
|
ensure_src; ensure_db
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ taking screenshots cannot change anything on the instance.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -26,6 +27,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
from playwright.sync_api import Page, sync_playwright
|
from playwright.sync_api import Page, sync_playwright
|
||||||
|
|
||||||
from shots import SHOTS
|
from shots import SHOTS
|
||||||
@@ -80,7 +82,10 @@ def token() -> str:
|
|||||||
body = urllib.parse.urlencode({"username": USER, "password": PASSWORD}).encode()
|
body = urllib.parse.urlencode({"username": USER, "password": PASSWORD}).encode()
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(f"{BASE}/api/v1/auth/token", body) as res:
|
with urllib.request.urlopen(f"{BASE}/api/v1/auth/token", body) as res:
|
||||||
return json.load(res)["access_token"]
|
tok = json.load(res).get("access_token")
|
||||||
|
if not tok:
|
||||||
|
sys.exit(f"Login as {USER} needs MFA; the demo copy should have none (anonymize.py)")
|
||||||
|
return tok
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
sys.exit(f"Login as {USER} at {BASE} failed: {e} (is scripts/demo/up.sh start running?)")
|
sys.exit(f"Login as {USER} at {BASE} failed: {e} (is scripts/demo/up.sh start running?)")
|
||||||
state = json.loads(STATE.read_text())
|
state = json.loads(STATE.read_text())
|
||||||
@@ -118,6 +123,15 @@ def settle(page: Page) -> None:
|
|||||||
page.wait_for_timeout(800)
|
page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
|
||||||
|
def publish(png: bytes, path: Path, width: int) -> None:
|
||||||
|
"""Scale the 2x capture down to its published width and store it as WebP."""
|
||||||
|
img = Image.open(io.BytesIO(png)).convert("RGB")
|
||||||
|
if img.width > width:
|
||||||
|
img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS)
|
||||||
|
img.save(path, "WEBP", quality=85, method=6)
|
||||||
|
print(f" -> {path.name} {img.width}x{img.height}, {path.stat().st_size // 1024} KB")
|
||||||
|
|
||||||
|
|
||||||
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||||
OUT.mkdir(parents=True, exist_ok=True)
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
terms = mask_terms()
|
terms = mask_terms()
|
||||||
@@ -160,6 +174,9 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
|||||||
if got_it.is_visible():
|
if got_it.is_visible():
|
||||||
got_it.click()
|
got_it.click()
|
||||||
page.wait_for_timeout(300)
|
page.wait_for_timeout(300)
|
||||||
|
for sel in shot.clicks:
|
||||||
|
page.locator(sel).first.click()
|
||||||
|
settle(page)
|
||||||
masks = [page.locator(s) for s in shot.mask]
|
masks = [page.locator(s) for s in shot.mask]
|
||||||
if mask:
|
if mask:
|
||||||
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
|
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
|
||||||
@@ -167,8 +184,9 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
|||||||
clip = None
|
clip = None
|
||||||
if shot.height:
|
if shot.height:
|
||||||
clip = {"x": 0, "y": 0, "width": VIEWPORT["width"], "height": shot.height}
|
clip = {"x": 0, "y": 0, "width": VIEWPORT["width"], "height": shot.height}
|
||||||
page.screenshot(path=OUT / f"{shot.name}.png", full_page=shot.full_page,
|
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
|
||||||
clip=clip, mask=masks, mask_color="#334155", animations="disabled")
|
mask_color="#334155", animations="disabled")
|
||||||
|
publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||||
browser.close()
|
browser.close()
|
||||||
if blocked:
|
if blocked:
|
||||||
print("Blocked non-GET requests (nothing was sent):")
|
print("Blocked non-GET requests (nothing was sent):")
|
||||||
|
|||||||
@@ -23,15 +23,26 @@ class Shot:
|
|||||||
full_page: bool = False
|
full_page: bool = False
|
||||||
# Crop height in CSS pixels; None keeps the viewport height.
|
# Crop height in CSS pixels; None keeps the viewport height.
|
||||||
height: int | None = None
|
height: int | None = None
|
||||||
|
# Width of the published WebP in pixels (the capture is 3200 wide).
|
||||||
|
width: int = 1600
|
||||||
|
# Selectors clicked in order before the shot, first match each. Only for
|
||||||
|
# controls that change the view (filters, tabs); the API guard in
|
||||||
|
# capture.py aborts anything that would write.
|
||||||
|
clicks: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
SHOTS: list[Shot] = [
|
SHOTS: list[Shot] = [
|
||||||
Shot("devices", "/devices"),
|
Shot("devices", "/devices", width=2400),
|
||||||
Shot("device-detail", "/devices/{ap}#networking/interfaces"),
|
Shot("device-detail", "/devices/{ap}#networking/interfaces"),
|
||||||
Shot("vlans", "/vlans"),
|
Shot("vlans", "/vlans"),
|
||||||
Shot("device-security", "/devices/{server}#security/assessment"),
|
Shot("device-security", "/devices/{server}#security/assessment"),
|
||||||
Shot("vulnerabilities", "/vulnerabilities"),
|
Shot("vulnerabilities", "/vulnerabilities"),
|
||||||
Shot("dashboard", "/"),
|
Shot("dashboard", "/"),
|
||||||
Shot("audit-log", "/audit-log"),
|
# Background polls drown out what people did: filter the scheduler out,
|
||||||
|
# the way a reader would (click a source badge, then flip it to exclude).
|
||||||
|
Shot("audit-log", "/audit-log", clicks=[
|
||||||
|
"tbody td >> text=scheduler",
|
||||||
|
"button[title='Click to toggle include/exclude']",
|
||||||
|
]),
|
||||||
Shot("service-checks", "/monitoring/checks"),
|
Shot("service-checks", "/monitoring/checks"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -68,26 +68,40 @@ const en = {
|
|||||||
},
|
},
|
||||||
screenshot2: {
|
screenshot2: {
|
||||||
heading: 'Intent-based VLAN and SSID management',
|
heading: 'Intent-based VLAN and SSID management',
|
||||||
body: "Define VLAN names and SSID settings once. netOrk compares them against every polled device and pushes corrections automatically via UCI (OpenWRT) or the device's native API.",
|
body: "Define VLAN names and SSID settings once. netOrk compares them against every polled device, shows each difference, and corrects it via UCI (OpenWrt) or the device's native API — automatically only where you switched that on.",
|
||||||
},
|
},
|
||||||
screenshot3: {
|
screenshot3: {
|
||||||
heading: 'Security visibility per device',
|
heading: 'A security assessment for every device',
|
||||||
body: 'Wazuh agent status, CVE counts by severity, and recent alerts — all linked to the device record. One-click agent install if the agent is missing. Graylog syslog forwarding status with auto-fix.',
|
body: 'TLS and SSH graded A to F, installed software and container images matched against known vulnerabilities, hardening benchmarks and network exposure — rated by what each finding means on this device, with no agent to install.',
|
||||||
},
|
},
|
||||||
screenshot4: {
|
screenshot4: {
|
||||||
heading: 'Configuration backup and versioning',
|
heading: 'One triage queue, decisions that hold',
|
||||||
body: 'Every poll captures a config snapshot into a local Git repository. The Config tab shows the full snapshot history, a side-by-side diff between any two points in time, and — for OPNsense — a Restore button. Unauthorized changes show up as a device warning.',
|
body: 'Every vulnerability across every device in one list, most urgent first, each row saying why. Mark it not applicable, accept or defer it until a date, or fixed — always with a reason. Deferrals come back on their own, and fixes are verified by the next assessments.',
|
||||||
},
|
},
|
||||||
screenshot5: {
|
screenshot5: {
|
||||||
heading: 'Dashboards you actually build',
|
heading: 'Dashboards you actually build',
|
||||||
body: 'Pick from 13 widgets and arrange them on a WYSIWYG grid — no more fixed layout. Share a dashboard with a colleague, let them subscribe to your live version or clone it into their own, and pin favorites to the main menu.',
|
body: 'Pick from 18 widgets and arrange them on a WYSIWYG grid — no more fixed layout. Share a dashboard with a colleague, let them subscribe to your live version or clone it into their own, and pin favorites to the main menu.',
|
||||||
|
},
|
||||||
|
screenshot6: {
|
||||||
|
heading: 'Service checks every minute',
|
||||||
|
body: 'DNS, time servers, VPN tunnels, core daemons and gateways are checked about once a minute — derived from what netOrk already knows, with nothing to set up. A check reports after three failures in a row, and a whole site going down is one message, not fifty.',
|
||||||
|
},
|
||||||
|
shotAlt: {
|
||||||
|
devices: 'netOrk device inventory with hostnames, vendors, device types, sites and status',
|
||||||
|
deviceDetail: 'Interfaces of an access point in netOrk, with VLANs, neighbors and link status',
|
||||||
|
vlans: 'VLAN list in netOrk, grouped by site with device counts',
|
||||||
|
deviceSecurity: 'Security tab of a server in netOrk: warnings, configuration grade and vulnerability counts',
|
||||||
|
vulnerabilities: 'Vulnerability triage queue in netOrk with severity, reason and affected devices',
|
||||||
|
dashboard: 'netOrk dashboard with device statistics, warnings, certificate expiry and scheduled actions',
|
||||||
|
serviceChecks: 'Service checks in netOrk: DNS, gateways and other checks with state and latency',
|
||||||
|
auditLog: 'netOrk audit log with user actions and background task results, exportable as CSV and PDF',
|
||||||
},
|
},
|
||||||
nis2Label: 'NIS2 · Art. 21',
|
nis2Label: 'NIS2 · Art. 21',
|
||||||
nis2Heading: 'Evidence, not paperwork.',
|
nis2Heading: 'Evidence, not paperwork.',
|
||||||
nis2Body: "NIS2 Art. 21 mandates asset inventory, patch management, access control, and audit trails as baseline technical measures. netOrk doesn't bolt on a compliance layer — these are its day-to-day outputs.",
|
nis2Body: "NIS2 Art. 21 mandates asset inventory, patch management, access control, and audit trails as baseline technical measures. netOrk doesn't bolt on a compliance layer — these are its day-to-day outputs.",
|
||||||
nis2Link: 'Full Art. 21 mapping →',
|
nis2Link: 'Full Art. 21 mapping →',
|
||||||
nis2Items: [
|
nis2Items: [
|
||||||
{ art: 'Art. 21 (2e)', label: 'Patch & vulnerability management', detail: 'Per-device update status, Wazuh CVE counts by severity' },
|
{ art: 'Art. 21 (2e)', label: 'Patch & vulnerability management', detail: 'Per-device update status, vulnerability triage with reasoned, audited decisions' },
|
||||||
{ art: 'Art. 21 (2h)', label: 'Asset management & access control', detail: 'Full device inventory, RBAC with four roles, complete audit log' },
|
{ art: 'Art. 21 (2h)', label: 'Asset management & access control', detail: 'Full device inventory, RBAC with four roles, complete audit log' },
|
||||||
{ art: 'Art. 21 (2a)', label: 'Risk analysis baseline', detail: 'Config drift detection, SNMP health metrics, security agent coverage' },
|
{ art: 'Art. 21 (2a)', label: 'Risk analysis baseline', detail: 'Config drift detection, SNMP health metrics, security agent coverage' },
|
||||||
{ art: 'Art. 21 (2b)', label: 'Incident detection', detail: 'Wazuh alert history, CrowdSec decisions, Graylog syslog per device' },
|
{ art: 'Art. 21 (2b)', label: 'Incident detection', detail: 'Wazuh alert history, CrowdSec decisions, Graylog syslog per device' },
|
||||||
@@ -96,7 +110,7 @@ const en = {
|
|||||||
pluginsBody: 'Integrations (Wazuh, Graylog, CrowdSec, apt-cacher-ng, EOL Tracking) are plugins that register into the plugin system — they can be enabled or disabled per deployment without code changes. Adding a new integration follows a documented pattern with a hook bus, typed metadata, and a plugin registry.',
|
pluginsBody: 'Integrations (Wazuh, Graylog, CrowdSec, apt-cacher-ng, EOL Tracking) are plugins that register into the plugin system — they can be enabled or disabled per deployment without code changes. Adding a new integration follows a documented pattern with a hook bus, typed metadata, and a plugin registry.',
|
||||||
pluginsLink: 'Plugin system docs →',
|
pluginsLink: 'Plugin system docs →',
|
||||||
deployHeading: 'Self-hosted. One command.',
|
deployHeading: 'Self-hosted. One command.',
|
||||||
deployBody: 'netOrk runs in Docker Compose. Five containers: API, two worker pools, a Beat scheduler, and an nginx UI server. No external dependencies beyond Redis and PostgreSQL.',
|
deployBody: 'netOrk runs in Docker Compose: the API, three worker pools, a Beat scheduler and an nginx UI server, alongside Flower, a local image registry, an APT cache and a Signal gateway for notifications. Redis and PostgreSQL are all it needs besides — bundled, or your own database.',
|
||||||
ctaHeading: 'Interested?',
|
ctaHeading: 'Interested?',
|
||||||
ctaBody: 'Deployment options and hosted plans are coming. Get in touch for early access.',
|
ctaBody: 'Deployment options and hosted plans are coming. Get in touch for early access.',
|
||||||
ctaButton: 'Get in touch →',
|
ctaButton: 'Get in touch →',
|
||||||
@@ -563,26 +577,40 @@ const de: Translations = {
|
|||||||
},
|
},
|
||||||
screenshot2: {
|
screenshot2: {
|
||||||
heading: 'Intent-basiertes VLAN- und SSID-Management',
|
heading: 'Intent-basiertes VLAN- und SSID-Management',
|
||||||
body: 'VLAN-Namen und SSID-Einstellungen einmal definieren. netOrk vergleicht sie bei jeder Abfrage mit jedem Gerät und korrigiert Abweichungen automatisch via UCI (OpenWRT) oder der nativen Geräte-API.',
|
body: 'VLAN-Namen und SSID-Einstellungen einmal definieren. netOrk vergleicht sie bei jeder Abfrage mit jedem Gerät, zeigt jede Abweichung und korrigiert sie via UCI (OpenWrt) oder der nativen Geräte-API — automatisch nur dort, wo du das eingeschaltet hast.',
|
||||||
},
|
},
|
||||||
screenshot3: {
|
screenshot3: {
|
||||||
heading: 'Sicherheitssichtbarkeit pro Gerät',
|
heading: 'Eine Sicherheitsbewertung für jedes Gerät',
|
||||||
body: 'Wazuh-Agent-Status, CVE-Anzahl nach Schweregrad und aktuelle Alerts — alle mit dem Gerätedatensatz verknüpft. Ein-Klick-Agent-Installation falls der Agent fehlt. Graylog-Syslog-Weiterleitungsstatus mit Auto-Fix.',
|
body: 'TLS und SSH mit Note A bis F, installierte Software und Container-Images gegen bekannte Schwachstellen abgeglichen, Härtungs-Benchmarks und Netz-Exposition — bewertet danach, was ein Befund auf genau diesem Gerät bedeutet, ohne Agent.',
|
||||||
},
|
},
|
||||||
screenshot4: {
|
screenshot4: {
|
||||||
heading: 'Konfigurationsbackup und -versionierung',
|
heading: 'Eine Triage-Queue, Entscheidungen, die halten',
|
||||||
body: 'Bei jedem Poll wird ein Konfigurationssnapshot in einem lokalen Git-Repository gespeichert. Der Config-Tab zeigt die vollständige Snapshot-Historie, einen Side-by-Side-Diff zwischen beliebigen Zeitpunkten und — für OPNsense — einen Restore-Button. Nicht autorisierte Änderungen erscheinen als Gerätewarnung.',
|
body: 'Jede Schwachstelle über alle Geräte in einer Liste, das Dringendste zuerst, jede Zeile mit Begründung. Als nicht zutreffend markieren, bis zu einem Datum akzeptieren oder zurückstellen, oder als behoben — immer mit Begründung. Zurückgestelltes kommt von selbst zurück, Behebungen verifizieren die nächsten Bewertungen.',
|
||||||
},
|
},
|
||||||
screenshot5: {
|
screenshot5: {
|
||||||
heading: 'Dashboards, die du wirklich selbst baust',
|
heading: 'Dashboards, die du wirklich selbst baust',
|
||||||
body: 'Aus 13 Widgets wählen und auf einem WYSIWYG-Grid anordnen — kein festes Layout mehr. Ein Dashboard mit einem Kollegen teilen, der es abonnieren oder in eine eigene Kopie klonen kann, und Favoriten im Hauptmenü anpinnen.',
|
body: 'Aus 18 Widgets wählen und auf einem WYSIWYG-Grid anordnen — kein festes Layout mehr. Ein Dashboard mit einem Kollegen teilen, der es abonnieren oder in eine eigene Kopie klonen kann, und Favoriten im Hauptmenü anpinnen.',
|
||||||
|
},
|
||||||
|
screenshot6: {
|
||||||
|
heading: 'Service-Checks jede Minute',
|
||||||
|
body: 'DNS, Zeitserver, VPN-Tunnel, Kerndienste und Gateways werden etwa einmal pro Minute geprüft — abgeleitet aus dem, was netOrk ohnehin weiß, ohne Einrichtung. Ein Check meldet sich nach drei Fehlschlägen in Folge, und ein ganzer Standort, der ausfällt, ist eine Nachricht statt fünfzig.',
|
||||||
|
},
|
||||||
|
shotAlt: {
|
||||||
|
devices: 'Geräteinventar in netOrk mit Hostnamen, Herstellern, Gerätetypen, Standorten und Status',
|
||||||
|
deviceDetail: 'Schnittstellen eines Access Points in netOrk, mit VLANs, Nachbarn und Link-Status',
|
||||||
|
vlans: 'VLAN-Liste in netOrk, nach Standort gruppiert mit Geräteanzahl',
|
||||||
|
deviceSecurity: 'Security-Tab eines Servers in netOrk: Warnungen, Konfigurationsnote und Schwachstellenanzahl',
|
||||||
|
vulnerabilities: 'Schwachstellen-Triage-Queue in netOrk mit Schweregrad, Begründung und betroffenen Geräten',
|
||||||
|
dashboard: 'netOrk-Dashboard mit Gerätestatistik, Warnungen, Zertifikatsablauf und geplanten Aktionen',
|
||||||
|
serviceChecks: 'Service-Checks in netOrk: DNS, Gateways und weitere Checks mit Zustand und Latenz',
|
||||||
|
auditLog: 'Audit-Log in netOrk mit Benutzeraktionen und Ergebnissen von Hintergrundaufgaben, exportierbar als CSV und PDF',
|
||||||
},
|
},
|
||||||
nis2Label: 'NIS2 · Art. 21',
|
nis2Label: 'NIS2 · Art. 21',
|
||||||
nis2Heading: 'Nachweise, keine Papierwüste.',
|
nis2Heading: 'Nachweise, keine Papierwüste.',
|
||||||
nis2Body: 'NIS2 Art. 21 schreibt Geräteinventar, Patch-Management, Zugangskontrolle und Audit-Trails als technische Basismaßnahmen vor. netOrk fügt keine Compliance-Schicht auf — das sind seine normalen Tagesausgaben.',
|
nis2Body: 'NIS2 Art. 21 schreibt Geräteinventar, Patch-Management, Zugangskontrolle und Audit-Trails als technische Basismaßnahmen vor. netOrk fügt keine Compliance-Schicht auf — das sind seine normalen Tagesausgaben.',
|
||||||
nis2Link: 'Vollständiges Art. 21-Mapping →',
|
nis2Link: 'Vollständiges Art. 21-Mapping →',
|
||||||
nis2Items: [
|
nis2Items: [
|
||||||
{ art: 'Art. 21 (2e)', label: 'Patch- & Schwachstellen-Management', detail: 'Update-Status pro Gerät, Wazuh-CVE-Anzahl nach Schweregrad' },
|
{ art: 'Art. 21 (2e)', label: 'Patch- & Schwachstellen-Management', detail: 'Update-Status pro Gerät, Schwachstellen-Triage mit begründeten, protokollierten Entscheidungen' },
|
||||||
{ art: 'Art. 21 (2h)', label: 'Asset-Management & Zugangskontrolle', detail: 'Vollständiges Geräteinventar, RBAC mit vier Rollen, vollständiges Audit-Log' },
|
{ art: 'Art. 21 (2h)', label: 'Asset-Management & Zugangskontrolle', detail: 'Vollständiges Geräteinventar, RBAC mit vier Rollen, vollständiges Audit-Log' },
|
||||||
{ art: 'Art. 21 (2a)', label: 'Risikoanalyse-Baseline', detail: 'Konfigurationsdrift-Erkennung, SNMP-Gesundheitsmetriken, Security-Agent-Abdeckung' },
|
{ art: 'Art. 21 (2a)', label: 'Risikoanalyse-Baseline', detail: 'Konfigurationsdrift-Erkennung, SNMP-Gesundheitsmetriken, Security-Agent-Abdeckung' },
|
||||||
{ art: 'Art. 21 (2b)', label: 'Incident-Erkennung', detail: 'Wazuh-Alert-Historie, CrowdSec-Entscheidungen, Graylog-Syslog pro Gerät' },
|
{ art: 'Art. 21 (2b)', label: 'Incident-Erkennung', detail: 'Wazuh-Alert-Historie, CrowdSec-Entscheidungen, Graylog-Syslog pro Gerät' },
|
||||||
@@ -591,7 +619,7 @@ const de: Translations = {
|
|||||||
pluginsBody: 'Integrationen (Wazuh, Graylog, CrowdSec, apt-cacher-ng, EOL-Tracking) sind Plugins, die im Plugin-System registriert werden — sie lassen sich pro Deployment ohne Code-Änderungen aktivieren oder deaktivieren. Eine neue Integration folgt einem dokumentierten Muster mit Hook-Bus, typisiertem Metadatum und Plugin-Registry.',
|
pluginsBody: 'Integrationen (Wazuh, Graylog, CrowdSec, apt-cacher-ng, EOL-Tracking) sind Plugins, die im Plugin-System registriert werden — sie lassen sich pro Deployment ohne Code-Änderungen aktivieren oder deaktivieren. Eine neue Integration folgt einem dokumentierten Muster mit Hook-Bus, typisiertem Metadatum und Plugin-Registry.',
|
||||||
pluginsLink: 'Plugin-System-Dokumentation →',
|
pluginsLink: 'Plugin-System-Dokumentation →',
|
||||||
deployHeading: 'Self-hosted. Ein Befehl.',
|
deployHeading: 'Self-hosted. Ein Befehl.',
|
||||||
deployBody: 'netOrk läuft in Docker Compose. Fünf Container: API, zwei Worker-Pools, ein Beat-Scheduler und ein nginx-UI-Server. Keine externen Abhängigkeiten außer Redis und PostgreSQL.',
|
deployBody: 'netOrk läuft in Docker Compose: die API, drei Worker-Pools, ein Beat-Scheduler und ein nginx-UI-Server, daneben Flower, eine lokale Image-Registry, ein APT-Cache und ein Signal-Gateway für Benachrichtigungen. Sonst braucht es nur Redis und PostgreSQL — mitgeliefert oder die eigene Datenbank.',
|
||||||
ctaHeading: 'Interesse?',
|
ctaHeading: 'Interesse?',
|
||||||
ctaBody: 'Deployment-Optionen und gehostete Pläne folgen. Jetzt Kontakt aufnehmen für Early Access.',
|
ctaBody: 'Deployment-Optionen und gehostete Pläne folgen. Jetzt Kontakt aufnehmen für Early Access.',
|
||||||
ctaButton: 'Kontakt aufnehmen →',
|
ctaButton: 'Kontakt aufnehmen →',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const DRIVERS = [
|
|||||||
'TP-Link JetStream', 'Yealink', 'Zyxel VMG',
|
'TP-Link JetStream', 'Yealink', 'Zyxel VMG',
|
||||||
]
|
]
|
||||||
|
|
||||||
function BrowserFrame({ label, children }: { label: string; children: React.ReactNode }) {
|
function Screenshot({ src, label, alt }: { src: string; label: string; alt: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
||||||
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
|
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
|
||||||
@@ -24,282 +24,7 @@ function BrowserFrame({ label, children }: { label: string; children: React.Reac
|
|||||||
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||||
<span className="ml-4 text-xs text-slate-500 font-mono">{label}</span>
|
<span className="ml-4 text-xs text-slate-500 font-mono">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
<img src={src} alt={alt} loading="lazy" className="block w-full" width={1600} height={1000} />
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: 'active' | 'warning' | 'offline' }) {
|
|
||||||
const cls =
|
|
||||||
status === 'active' ? 'bg-green-500/20 text-green-400' :
|
|
||||||
status === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
|
|
||||||
'bg-red-500/20 text-red-400'
|
|
||||||
return (
|
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${cls}`}>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockDeviceList() {
|
|
||||||
const rows = [
|
|
||||||
{ name: 'fw-001.lan', ip: '10.0.0.1', driver: 'OPNsense', status: 'active' as const },
|
|
||||||
{ name: 'sw-core.lan', ip: '10.0.0.2', driver: 'HP ProCurve', status: 'warning' as const },
|
|
||||||
{ name: 'ap-001.lan', ip: '10.0.0.10', driver: 'OpenWRT', status: 'active' as const },
|
|
||||||
{ name: 'ap-002.lan', ip: '10.0.0.11', driver: 'OpenWRT', status: 'warning' as const },
|
|
||||||
{ name: 'pve-01.lan', ip: '10.0.0.20', driver: 'Proxmox VE', status: 'active' as const },
|
|
||||||
{ name: 'nas-01.lan', ip: '10.0.0.30', driver: 'OpenMediaVault', status: 'active' as const },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-950 p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold text-slate-100">Devices</span>
|
|
||||||
<span className="text-xs px-2.5 py-1 rounded-md border border-slate-700 text-slate-400">Discovery</span>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-slate-800 overflow-hidden">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-800 bg-slate-900">
|
|
||||||
<th className="px-3 py-2 text-left font-medium text-slate-500">Hostname</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium text-slate-500">IP</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium text-slate-500 hidden sm:table-cell">Driver</th>
|
|
||||||
<th className="px-3 py-2 text-left font-medium text-slate-500">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr key={r.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40">
|
|
||||||
<td className="px-3 py-2 font-mono text-sky-400">{r.name}</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-slate-400">{r.ip}</td>
|
|
||||||
<td className="px-3 py-2 text-slate-400 hidden sm:table-cell">{r.driver}</td>
|
|
||||||
<td className="px-3 py-2"><StatusBadge status={r.status} /></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockDeviceDetail() {
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-950 p-4">
|
|
||||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4 mb-3">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-slate-100">ap-001.lan</p>
|
|
||||||
<p className="text-xs text-slate-500 font-mono">10.0.0.10</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status="active" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div><span className="text-slate-500">Vendor</span><p className="text-slate-300">GL.iNet</p></div>
|
|
||||||
<div><span className="text-slate-500">OS</span><p className="text-slate-300">OpenWRT 23.05</p></div>
|
|
||||||
<div><span className="text-slate-500">Driver</span><p className="text-slate-300">openwrt</p></div>
|
|
||||||
<div><span className="text-slate-500">Last poll</span><p className="text-slate-300">2 min ago</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 text-xs mb-3">
|
|
||||||
{['Interfaces', 'LLDP', 'ARP', 'VLANs', 'Services'].map((t) => (
|
|
||||||
<span key={t} className={`px-2 py-1 rounded text-xs ${t === 'Interfaces' ? 'bg-sky-600/20 text-sky-400' : 'text-slate-500 hover:text-slate-300'}`}>{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-slate-800 overflow-hidden">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead><tr className="bg-slate-900 border-b border-slate-800">
|
|
||||||
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">Interface</th>
|
|
||||||
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">IP</th>
|
|
||||||
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">State</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{[['br-lan', '10.0.0.10/24', 'up'], ['wlan0', '—', 'up'], ['eth0', '—', 'up']].map(([iface, ip, state]) => (
|
|
||||||
<tr key={iface} className="border-b border-slate-800 last:border-0">
|
|
||||||
<td className="px-3 py-1.5 font-mono text-slate-300">{iface}</td>
|
|
||||||
<td className="px-3 py-1.5 font-mono text-slate-400">{ip}</td>
|
|
||||||
<td className="px-3 py-1.5"><span className="text-green-400">{state}</span></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockVlans() {
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-950 p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold text-slate-100">VLANs</span>
|
|
||||||
<span className="text-xs px-2.5 py-1 rounded-md bg-sky-600 text-white">+ Add VLAN</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[
|
|
||||||
{ id: 10, name: 'management', devices: 6, drift: false },
|
|
||||||
{ id: 20, name: 'iot', devices: 4, drift: true },
|
|
||||||
{ id: 30, name: 'servers', devices: 3, drift: false },
|
|
||||||
{ id: 40, name: 'guest', devices: 2, drift: false },
|
|
||||||
].map((v) => (
|
|
||||||
<div key={v.id} className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900 px-3 py-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xs font-mono text-slate-500 w-8">#{v.id}</span>
|
|
||||||
<span className="text-xs text-slate-300">{v.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xs text-slate-500">{v.devices} devices</span>
|
|
||||||
{v.drift
|
|
||||||
? <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">drift</span>
|
|
||||||
: <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">clean</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
|
|
||||||
<p className="text-xs text-yellow-400 font-medium mb-1">Drift detected — VLAN 20 (iot)</p>
|
|
||||||
<p className="text-xs text-slate-400">ap-002.lan: VLAN name mismatch. Expected "iot", got "IoT-devices".</p>
|
|
||||||
<button className="mt-2 text-xs px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-500 text-white transition-colors">Fix now</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockConfigDiff() {
|
|
||||||
const snapshots = [
|
|
||||||
{ id: 'a3f9c1', when: '2 min ago', label: 'current' },
|
|
||||||
{ id: '7e2b04', when: '1 h ago' },
|
|
||||||
{ id: 'd819e6', when: '6 h ago' },
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-950 p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold text-slate-100">Config — fw-001.lan</span>
|
|
||||||
<button className="text-xs px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-500 text-white transition-colors">Restore</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mb-3">
|
|
||||||
{snapshots.map((s) => (
|
|
||||||
<span key={s.id} className={`text-xs font-mono px-2 py-1 rounded border ${s.label ? 'border-sky-500/40 bg-sky-500/10 text-sky-400' : 'border-slate-800 text-slate-500'}`}>
|
|
||||||
{s.id} <span className="text-slate-600">· {s.when}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-slate-800 overflow-hidden font-mono text-xs">
|
|
||||||
<div className="px-3 py-1.5 bg-slate-900 text-slate-500 border-b border-slate-800">7e2b04 → a3f9c1</div>
|
|
||||||
<div className="px-3 py-1 bg-red-500/10 text-red-400">- set firewall.rule_42.destination_port='22'</div>
|
|
||||||
<div className="px-3 py-1 bg-green-500/10 text-green-400">+ set firewall.rule_42.destination_port='2222'</div>
|
|
||||||
<div className="px-3 py-1 text-slate-500"> commit</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
|
|
||||||
<p className="text-xs text-yellow-400 font-medium">Unauthorized change detected — fw-001.lan</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-1">Configuration changed outside netOrk between the last two polls.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockDashboard() {
|
|
||||||
const widgets = [
|
|
||||||
{ label: 'Stats', span: 'col-span-2' },
|
|
||||||
{ label: 'Device Warnings', span: 'col-span-1' },
|
|
||||||
{ label: 'Network Topology', span: 'col-span-2' },
|
|
||||||
{ label: 'EOL Status', span: 'col-span-1' },
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-950 p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold text-slate-100">My Dashboard</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-500/15 text-sky-400 border border-sky-500/20">shared</span>
|
|
||||||
<span className="text-xs px-2.5 py-1 rounded-md bg-sky-600 text-white">+ Add Widget</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{widgets.map((w) => (
|
|
||||||
<div key={w.label} className={`${w.span} rounded-lg border border-dashed border-slate-700 bg-slate-900 p-3 h-16 flex items-center justify-center`}>
|
|
||||||
<span className="text-xs text-slate-500">{w.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockCompliance() {
|
|
||||||
const checks = [
|
|
||||||
{ label: 'Asset inventory', detail: '18 / 18 devices tracked', ok: true },
|
|
||||||
{ label: 'Patch status', detail: 'All devices polled < 60 min', ok: true },
|
|
||||||
{ label: 'Wazuh agents', detail: '15 / 18 agents active', ok: false },
|
|
||||||
{ label: 'Syslog forwarding', detail: '14 / 18 forwarding to Graylog', ok: false },
|
|
||||||
{ label: 'Config drift', detail: '0 drifted devices', ok: true },
|
|
||||||
{ label: 'Audit log', detail: '23 actions logged (24 h)', ok: true },
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-950 p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold text-slate-100">Compliance overview — HQ</span>
|
|
||||||
<span className="text-xs font-mono text-sky-500">Art. 21 NIS2</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{checks.map((c) => (
|
|
||||||
<div key={c.label} className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900 px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className={`text-xs font-semibold w-3 ${c.ok ? 'text-green-400' : 'text-yellow-400'}`}>
|
|
||||||
{c.ok ? '✓' : '⚠'}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-300">{c.label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-500">{c.detail}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockSecurity() {
|
|
||||||
return (
|
|
||||||
<div className="bg-slate-950 p-4">
|
|
||||||
<div className="mb-3">
|
|
||||||
<span className="text-xs font-semibold text-slate-100">Security — ap-001.lan</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs font-medium text-slate-300">Wazuh Agent</span>
|
|
||||||
<StatusBadge status="active" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
||||||
<div className="text-center rounded bg-red-500/10 border border-red-500/20 py-1.5">
|
|
||||||
<p className="text-red-400 font-semibold">2</p>
|
|
||||||
<p className="text-slate-500">Critical</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center rounded bg-yellow-500/10 border border-yellow-500/20 py-1.5">
|
|
||||||
<p className="text-yellow-400 font-semibold">7</p>
|
|
||||||
<p className="text-slate-500">High</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center rounded bg-blue-500/10 border border-blue-500/20 py-1.5">
|
|
||||||
<p className="text-blue-400 font-semibold">14</p>
|
|
||||||
<p className="text-slate-500">Medium</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-slate-300">Graylog syslog</span>
|
|
||||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">forwarding</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">10.0.0.50:514 (UDP)</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-slate-300">CrowdSec</span>
|
|
||||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">active</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 mt-1">3 decisions · 0 bans (24 h)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -336,9 +61,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-7xl mx-auto px-6 mt-16">
|
<div className="max-w-7xl mx-auto px-6 mt-16">
|
||||||
<BrowserFrame label="netork.local / devices">
|
<Screenshot src="/screenshots/devices.webp" label="netork / devices" alt={h.shotAlt.devices} />
|
||||||
<MockDeviceList />
|
|
||||||
</BrowserFrame>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -414,45 +137,42 @@ export default function Home() {
|
|||||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot1.heading}</h2>
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot1.heading}</h2>
|
||||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot1.body)}</p>
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot1.body)}</p>
|
||||||
</div>
|
</div>
|
||||||
<BrowserFrame label="netork.local / devices / ap-001">
|
<Screenshot src="/screenshots/device-detail.webp" label="netork / devices / ap-floor1" alt={h.shotAlt.deviceDetail} />
|
||||||
<MockDeviceDetail />
|
|
||||||
</BrowserFrame>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
<BrowserFrame label="netork.local / vlans">
|
<div className="md:order-2">
|
||||||
<MockVlans />
|
|
||||||
</BrowserFrame>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot2.heading}</h2>
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot2.heading}</h2>
|
||||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot2.body)}</p>
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot2.body)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Screenshot src="/screenshots/vlans.webp" label="netork / vlans" alt={h.shotAlt.vlans} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot3.heading}</h2>
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot3.heading}</h2>
|
||||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot3.body)}</p>
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot3.body)}</p>
|
||||||
</div>
|
</div>
|
||||||
<BrowserFrame label="netork.local / devices / ap-001 / security">
|
<Screenshot src="/screenshots/device-security.webp" label="netork / devices / proxy-01 / security" alt={h.shotAlt.deviceSecurity} />
|
||||||
<MockSecurity />
|
|
||||||
</BrowserFrame>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
<BrowserFrame label="netork.local / devices / fw-001 / config">
|
<div className="md:order-2">
|
||||||
<MockConfigDiff />
|
|
||||||
</BrowserFrame>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot4.heading}</h2>
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot4.heading}</h2>
|
||||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot4.body)}</p>
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot4.body)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Screenshot src="/screenshots/vulnerabilities.webp" label="netork / vulnerabilities" alt={h.shotAlt.vulnerabilities} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot5.heading}</h2>
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot5.heading}</h2>
|
||||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot5.body)}</p>
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot5.body)}</p>
|
||||||
</div>
|
</div>
|
||||||
<BrowserFrame label="netork.local / dashboards / my-dashboard">
|
<Screenshot src="/screenshots/dashboard.webp" label="netork / dashboard" alt={h.shotAlt.dashboard} />
|
||||||
<MockDashboard />
|
</div>
|
||||||
</BrowserFrame>
|
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div className="md:order-2">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot6.heading}</h2>
|
||||||
|
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot6.body)}</p>
|
||||||
|
</div>
|
||||||
|
<Screenshot src="/screenshots/service-checks.webp" label="netork / monitoring / checks" alt={h.shotAlt.serviceChecks} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -485,9 +205,7 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BrowserFrame label="netork.local / compliance / HQ">
|
<Screenshot src="/screenshots/audit-log.webp" label="netork / audit-log" alt={h.shotAlt.auditLog} />
|
||||||
<MockCompliance />
|
|
||||||
</BrowserFrame>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||