feat(screenshots): cropped shots, sizes for the site, a site check
- shots.py crops to a region (clip) or to what one or more elements cover (element, pad), per-shot viewport; capture.py writes the published sizes to src/data/screenshots.json so the page reserves the right space. - anonymize.py no longer empties secrets that netOrk compares with each other (Wi-Fi keys on an SSID against the key read from the access point). Emptying them invented passphrase "drift" that never existed; a keyed hash keeps equal equal, reverses nothing, and its key lives for one run. - scripts/check/site.py checks the built site in both languages at four widths: sideways overflow, one h1, images with alt and size, console errors, requests to other origins, links to unknown routes, old-URL redirects, language detection, and word counts against the budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d585f9272f
commit
369f66afdc
@@ -12,3 +12,6 @@ deploy.env
|
||||
# Screenshot tooling: bytecode, and database dumps that hold production data
|
||||
__pycache__/
|
||||
*.dump
|
||||
|
||||
# Output of scripts/check/site.py
|
||||
.check/
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the built site the way a visitor meets it.
|
||||
|
||||
npm run build && npx vite preview --port 4173 &
|
||||
scripts/check/site.py [--base http://127.0.0.1:4173] [--out .check]
|
||||
|
||||
Every route in both languages at 360, 390, 768 and 1440 px:
|
||||
- no sideways scrolling, exactly one h1, every image loaded with alt and size
|
||||
- no console errors, no request to any other origin
|
||||
- internal links only to routes that exist
|
||||
Plus: the old URLs redirect, the language follows the browser until someone
|
||||
chooses, and the word count of each page (Home EN fails above its budget).
|
||||
Full-page PNGs land in --out for looking at.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROUTES = ["/", "/pricing", "/nis2", "/plugins", "/glossary", "/docs/getting-started",
|
||||
"/for/it-department", "/for/it-support", "/for/msp", "/impressum", "/datenschutz"]
|
||||
REDIRECTS = {"/features": "/#included", "/drivers": "/#hardware", "/roadmap": "/", "/nope": "/"}
|
||||
WIDTHS = [360, 390, 768, 1440]
|
||||
# EN word budgets from docs/PAGES.md; only Home is a hard failure.
|
||||
BUDGETS = {"/": 400, "/pricing": 300, "/nis2": 550, "/plugins": 450, "/docs/getting-started": 80,
|
||||
"/for/it-department": 320, "/for/it-support": 320, "/for/msp": 320}
|
||||
EXTERNAL_OK = ("https://license.netork.io/", "mailto:")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--base", default="http://127.0.0.1:4173")
|
||||
ap.add_argument("--out", type=Path, default=Path(".check"))
|
||||
args = ap.parse_args()
|
||||
base = args.base.rstrip("/")
|
||||
origin = urlparse(base).netloc
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
problems: list[str] = []
|
||||
words: dict[tuple[str, str], int] = {}
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
|
||||
for lang in ("en", "de"):
|
||||
for width in WIDTHS:
|
||||
ctx = browser.new_context(viewport={"width": width, "height": 900})
|
||||
ctx.add_init_script(f"localStorage.setItem('lang', '{lang}')")
|
||||
page = ctx.new_page()
|
||||
errors: list[str] = []
|
||||
foreign: set[str] = set()
|
||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.on("request", lambda r: foreign.add(r.url) if urlparse(r.url).netloc not in (origin, "") and not r.url.startswith("data:") else None)
|
||||
|
||||
for route in ROUTES:
|
||||
where = f"{route} [{lang} {width}px]"
|
||||
errors.clear()
|
||||
page.goto(base + route)
|
||||
page.wait_for_load_state("networkidle")
|
||||
# Scroll through so lazy images load.
|
||||
height = page.evaluate("document.documentElement.scrollHeight")
|
||||
for y in range(0, height, 600):
|
||||
page.evaluate(f"window.scrollTo(0, {y})")
|
||||
page.wait_for_timeout(40)
|
||||
page.wait_for_timeout(300)
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
|
||||
overflow = page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth")
|
||||
if overflow > 0:
|
||||
problems.append(f"{where}: {overflow}px sideways overflow")
|
||||
h1 = page.locator("h1").count()
|
||||
if h1 != 1:
|
||||
problems.append(f"{where}: {h1} h1 elements")
|
||||
bad_imgs = page.evaluate("""[...document.images].filter(i =>
|
||||
!i.alt || !i.getAttribute('width') || !i.getAttribute('height') || !i.complete || i.naturalWidth === 0
|
||||
).map(i => i.currentSrc || i.src)""")
|
||||
for src in bad_imgs:
|
||||
problems.append(f"{where}: image missing alt/size or not loaded: {src}")
|
||||
for e in errors:
|
||||
problems.append(f"{where}: console: {e[:160]}")
|
||||
hrefs = page.evaluate("[...document.querySelectorAll('a[href]')].map(a => a.getAttribute('href'))")
|
||||
for href in hrefs:
|
||||
if href.startswith(EXTERNAL_OK):
|
||||
continue
|
||||
if href.startswith(("http:", "https:")):
|
||||
problems.append(f"{where}: unexpected external link {href}")
|
||||
continue
|
||||
path = href.split("#")[0] or "/"
|
||||
if path.startswith("/") and path not in ROUTES:
|
||||
problems.append(f"{where}: link to unknown route {href}")
|
||||
if width == 1440:
|
||||
words[(route, lang)] = page.evaluate("document.querySelector('main').innerText.split(/\\s+/).filter(Boolean).length")
|
||||
name = route.strip("/").replace("/", "-") or "home"
|
||||
page.screenshot(path=str(args.out / f"{name}-{lang}.png"), full_page=True)
|
||||
if width == 390:
|
||||
name = route.strip("/").replace("/", "-") or "home"
|
||||
page.screenshot(path=str(args.out / f"{name}-{lang}-390.png"), full_page=True)
|
||||
|
||||
for url in sorted(foreign):
|
||||
problems.append(f"[{lang} {width}px]: request to another origin: {url}")
|
||||
ctx.close()
|
||||
|
||||
# Old URLs.
|
||||
ctx = browser.new_context()
|
||||
page = ctx.new_page()
|
||||
for old, target in REDIRECTS.items():
|
||||
page.goto(base + old)
|
||||
page.wait_for_load_state("networkidle")
|
||||
got = urlparse(page.url)
|
||||
landed = got.path + (f"#{got.fragment}" if got.fragment else "")
|
||||
if landed != target:
|
||||
problems.append(f"redirect {old}: landed on {landed}, expected {target}")
|
||||
ctx.close()
|
||||
|
||||
# Language: the browser decides until someone chooses.
|
||||
for locale, stored, expected in [("de-DE", None, "de"), ("en-US", None, "en"), ("fr-FR", None, "en"),
|
||||
("de-DE", "en", "en")]:
|
||||
ctx = browser.new_context(locale=locale)
|
||||
if stored:
|
||||
ctx.add_init_script(f"localStorage.setItem('lang', '{stored}')")
|
||||
page = ctx.new_page()
|
||||
page.goto(base + "/")
|
||||
page.wait_for_load_state("networkidle")
|
||||
got = page.evaluate("document.documentElement.lang")
|
||||
stored_after = page.evaluate("localStorage.getItem('lang')")
|
||||
if got != expected:
|
||||
problems.append(f"language: locale {locale}, stored {stored}: got {got}, expected {expected}")
|
||||
if not stored and stored_after is not None:
|
||||
problems.append(f"language: locale {locale}: a choice was stored without anyone choosing")
|
||||
ctx.close()
|
||||
|
||||
browser.close()
|
||||
|
||||
print("words in <main> (EN / DE, budget):")
|
||||
for route in ROUTES:
|
||||
budget = BUDGETS.get(route)
|
||||
en, de = words.get((route, "en"), 0), words.get((route, "de"), 0)
|
||||
flag = " OVER" if budget and en > budget else ""
|
||||
print(f" {route:24} {en:5} / {de:5} {budget or '-'}{flag}")
|
||||
if words.get(("/", "en"), 0) > BUDGETS["/"]:
|
||||
problems.append(f"Home EN has {words[('/', 'en')]} words, budget {BUDGETS['/']}")
|
||||
|
||||
if problems:
|
||||
print(f"\n{len(problems)} problems:")
|
||||
for pr in problems:
|
||||
print(f" {pr}")
|
||||
return 1
|
||||
print("\nno problems")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -16,6 +16,10 @@ Log in as `netork` / `netork-demo`.
|
||||
- Only the API and the UI run. There is no worker, no beat and no Redis, so
|
||||
nothing polls or reaches a device. Stored credentials are emptied, and the
|
||||
encryption key is random per start.
|
||||
- Secrets are emptied — except the ones netOrk compares with each other (Wi-Fi
|
||||
keys on an SSID against the key read from the access point). Those become a
|
||||
keyed hash, so equal stays equal and the drift view shows the real state
|
||||
instead of invented deviations. The key exists only for one run.
|
||||
- The mapping from real to demo names lives outside the repo in
|
||||
`~/.config/netork-screenshots/demo-map.json`, because it lists the real names.
|
||||
Domains become `example.demo`.
|
||||
|
||||
@@ -33,6 +33,7 @@ import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -183,9 +184,19 @@ class Mapper:
|
||||
return ".".join(mapped[::-1]) + ".in-addr.arpa"
|
||||
|
||||
# -- whole strings -------------------------------------------------------
|
||||
@staticmethod
|
||||
def _secret(m: re.Match) -> str:
|
||||
name, sep = m.group(1), m.group(2)
|
||||
if name in SECRET_JSON_KEEP:
|
||||
return m.group(0)
|
||||
raw = m.group(0)[m.group(0).index(sep) + len(sep) + 1:-1]
|
||||
if not raw or not COMPARED_SECRET.match(name):
|
||||
return f'"{name}"{sep}""'
|
||||
value = json.loads(f'"{raw}"') # the value as the column would hold it
|
||||
return f'"{name}"{sep}"{secret_token(value)}"'
|
||||
|
||||
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 = SECRET_JSON.sub(self._secret, s)
|
||||
s = REVERSE.sub(self.reverse, s)
|
||||
s = EMAIL.sub(self.email, s)
|
||||
if self.domain_re:
|
||||
@@ -219,6 +230,18 @@ 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"}
|
||||
|
||||
# Secrets netOrk compares with each other (the Wi-Fi key stored on an SSID against
|
||||
# the key read from the access point). Emptying them would invent drift that never
|
||||
# existed, so they become a keyed hash instead: equal stays equal, nothing can be
|
||||
# reversed, and the key lives only for this run.
|
||||
COMPARED_SECRET = re.compile(r"^(passphrase|psk|ft_key|wpa_key|key|sae_password)$", re.I)
|
||||
RUN_KEY = secrets.token_hex(32)
|
||||
|
||||
|
||||
def secret_token(value: str) -> str:
|
||||
"""Same formula as the SQL in scrub_secrets: md5(run key || value)."""
|
||||
return "demo-" + hashlib.md5((RUN_KEY + value).encode()).hexdigest()[:16]
|
||||
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
|
||||
|
||||
# Whole tables that only hold secrets or personal delivery data.
|
||||
@@ -284,14 +307,22 @@ async def scrub_secrets(con, dry: bool) -> None:
|
||||
continue
|
||||
if dt not in ("text", "character varying", "jsonb", "json", "bytea"):
|
||||
continue # flags like require_password are booleans
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
|
||||
if not n:
|
||||
continue
|
||||
if COMPARED_SECRET.match(c) and dt in ("text", "character varying"):
|
||||
print(f" {t}.{c}: {n} replaced by keyed hash")
|
||||
if not dry:
|
||||
await con.execute(
|
||||
f'UPDATE "{t}" SET "{c}" = \'demo-\' || left(md5($1 || "{c}"), 16) '
|
||||
f'WHERE "{c}" IS NOT NULL AND "{c}" <> \'\'', RUN_KEY)
|
||||
continue
|
||||
value = "NULL" if nullable == "YES" else ("'{}'" if dt in ("jsonb", "json") else "''")
|
||||
if dt == "bytea" and nullable != "YES":
|
||||
value = "''::bytea"
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
|
||||
if n:
|
||||
print(f" {t}.{c}: {n} emptied")
|
||||
if not dry:
|
||||
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
|
||||
print(f" {t}.{c}: {n} emptied")
|
||||
if not dry:
|
||||
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
|
||||
# Settings flagged secret keep their key, lose their value.
|
||||
if await con.fetchval("SELECT to_regclass('public.settings') IS NOT NULL"):
|
||||
n = await con.fetchval("SELECT count(*) FROM settings WHERE is_secret")
|
||||
|
||||
@@ -5,7 +5,7 @@ Normally that instance is the local demo copy from scripts/demo (anonymized
|
||||
production data), which this script logs into on its own:
|
||||
|
||||
capture.py --list-devices # prints IDs to pick for --var
|
||||
capture.py --var ap=<id> --var server=<id> [--only name ...]
|
||||
capture.py --var ap=<id> [--only name ...]
|
||||
|
||||
Against a real instance, log in by hand and cover what must not be seen:
|
||||
|
||||
@@ -42,7 +42,8 @@ STATE = Path(os.environ.get(
|
||||
# One term per line: site names, customer names, domains ... never committed.
|
||||
MASK_FILE = Path(os.environ.get(
|
||||
"NETORK_MASK_FILE", Path.home() / ".config" / "netork-screenshots" / "mask.txt"))
|
||||
OUT = Path(__file__).resolve().parents[2] / "public" / "screenshots"
|
||||
SITE = Path(__file__).resolve().parents[2]
|
||||
OUT = SITE / "public" / "screenshots"
|
||||
|
||||
VIEWPORT = {"width": 1600, "height": 1000}
|
||||
|
||||
@@ -123,13 +124,14 @@ def settle(page: Page) -> None:
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def publish(png: bytes, path: Path, width: int) -> None:
|
||||
def publish(png: bytes, path: Path, width: int) -> dict[str, int]:
|
||||
"""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")
|
||||
return {"width": img.width, "height": img.height}
|
||||
|
||||
|
||||
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
@@ -137,6 +139,8 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
terms = mask_terms()
|
||||
tok = token() if LOCAL else None
|
||||
blocked: list[str] = []
|
||||
sizes_file = SITE / "src" / "data" / "screenshots.json"
|
||||
sizes: dict[str, dict[str, int]] = json.loads(sizes_file.read_text()) if sizes_file.exists() else {}
|
||||
|
||||
def guard(route):
|
||||
if route.request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
@@ -163,6 +167,8 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
print(f"skip {shot.name}: needs --var {e.args[0]}=<id>")
|
||||
continue
|
||||
print(f"{shot.name}: {path}")
|
||||
vw, vh = shot.viewport or (VIEWPORT["width"], VIEWPORT["height"])
|
||||
page.set_viewport_size({"width": vw, "height": vh})
|
||||
page.goto(f"{BASE}{path}")
|
||||
if page.url.rstrip("/").endswith("/login"):
|
||||
sys.exit("Session expired; run --login again.")
|
||||
@@ -182,12 +188,33 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
|
||||
masks += [page.get_by_text(t) for t in terms]
|
||||
clip = None
|
||||
if shot.height:
|
||||
clip = {"x": 0, "y": 0, "width": VIEWPORT["width"], "height": shot.height}
|
||||
if shot.clip:
|
||||
x, y, w, h = shot.clip
|
||||
clip = {"x": x, "y": y, "width": w, "height": h}
|
||||
elif shot.element:
|
||||
boxes = []
|
||||
for sel in ([shot.element] if isinstance(shot.element, str) else shot.element):
|
||||
el = page.locator(sel).first
|
||||
el.scroll_into_view_if_needed()
|
||||
b = el.bounding_box()
|
||||
if not b:
|
||||
sys.exit(f"{shot.name}: element not found: {sel}")
|
||||
boxes.append(b)
|
||||
left = min(b["x"] for b in boxes)
|
||||
top = min(b["y"] for b in boxes)
|
||||
right = max(b["x"] + b["width"] for b in boxes)
|
||||
bottom = max(b["y"] + b["height"] for b in boxes)
|
||||
box = {"x": left, "y": top, "width": right - left, "height": bottom - top}
|
||||
x0, y0 = max(0, box["x"] - shot.pad), max(0, box["y"] - shot.pad)
|
||||
clip = {"x": x0, "y": y0,
|
||||
"width": min(vw - x0, box["width"] + 2 * shot.pad),
|
||||
"height": min(vh - y0, box["height"] + 2 * shot.pad)}
|
||||
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
|
||||
mask_color="#334155", animations="disabled")
|
||||
publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||
sizes[shot.name] = publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||
browser.close()
|
||||
# The site reads these to reserve the right space for each image.
|
||||
sizes_file.write_text(json.dumps(dict(sorted(sizes.items())), indent=2) + "\n")
|
||||
if blocked:
|
||||
print("Blocked non-GET requests (nothing was sent):")
|
||||
for b in sorted(set(blocked)):
|
||||
|
||||
@@ -21,9 +21,15 @@ class Shot:
|
||||
wait_for: str = "main"
|
||||
mask: list[str] = field(default_factory=list)
|
||||
full_page: bool = False
|
||||
# Crop height in CSS pixels; None keeps the viewport height.
|
||||
height: int | None = None
|
||||
# Width of the published WebP in pixels (the capture is 3200 wide).
|
||||
# Crop to a region (x, y, width, height in CSS px of the viewport) ...
|
||||
clip: tuple[int, int, int, int] | None = None
|
||||
# ... or to one element, plus `pad` px around it. Cropping shows less of a
|
||||
# real screen; it never changes what is on it.
|
||||
element: str | list[str] | None = None # several: crop to what they cover together
|
||||
pad: int = 16
|
||||
# Viewport for this shot, (width, height) in CSS px; default in capture.py.
|
||||
viewport: tuple[int, int] | None = None
|
||||
# Width of the published WebP in pixels (captures are taken at 2x).
|
||||
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
|
||||
@@ -31,18 +37,26 @@ class Shot:
|
||||
clicks: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# The drift comparison on a device page: the summary line ("… (42 compared)") down to
|
||||
# the end of the table. The card around it stretches to the window height.
|
||||
DRIFT_CARD = ["xpath=//*[contains(text(), 'compared)')]", "xpath=//table[.//th[contains(., 'Expected')]]"]
|
||||
|
||||
SHOTS: list[Shot] = [
|
||||
Shot("devices", "/devices", width=2400),
|
||||
Shot("device-detail", "/devices/{ap}#networking/interfaces"),
|
||||
Shot("vlans", "/vlans"),
|
||||
Shot("device-security", "/devices/{server}#security/assessment"),
|
||||
Shot("vulnerabilities", "/vulnerabilities"),
|
||||
Shot("dashboard", "/"),
|
||||
# Home hero: an access point checked against its profile, wide and short.
|
||||
Shot("drift", "/devices/{ap}#drift", wait_for="main table", element=DRIFT_CARD,
|
||||
pad=28, viewport=(1440, 900), width=2400),
|
||||
# Phones: the same finding from "Parameter" to the status badge, readable at 390px.
|
||||
Shot("drift-narrow", "/devices/{ap}#drift", wait_for="main table",
|
||||
element=["xpath=//th[contains(., 'Parameter')]", "xpath=//th[contains(., 'Actual')]",
|
||||
"xpath=//tbody//td[contains(., 'Remote Syslog')]",
|
||||
"xpath=//tbody//span[contains(., 'Incomplete')]",
|
||||
"xpath=//tbody//*[starts-with(normalize-space(text()), 'Set this field')]"],
|
||||
pad=16, viewport=(1180, 900), width=1200),
|
||||
Shot("vulnerabilities", "/vulnerabilities", clip=(256, 40, 1344, 620), width=2400),
|
||||
# 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=[
|
||||
Shot("audit-log", "/audit-log", clip=(256, 40, 1344, 560), width=2400, clicks=[
|
||||
"tbody td >> text=scheduler",
|
||||
"button[title='Click to toggle include/exclude']",
|
||||
]),
|
||||
Shot("service-checks", "/monitoring/checks"),
|
||||
]
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Cap = '✓' | '—'
|
||||
|
||||
interface Driver {
|
||||
name: string
|
||||
deviceType: string
|
||||
facts: Cap
|
||||
interfaces: Cap
|
||||
lldp: Cap
|
||||
vlans: Cap
|
||||
ssids: Cap
|
||||
health: Cap
|
||||
docker: Cap
|
||||
reboot: Cap
|
||||
configPush: Cap
|
||||
status: 'stable' | 'beta'
|
||||
}
|
||||
|
||||
const DRIVERS: Driver[] = [
|
||||
{ name: 'fritzbox', deviceType: 'AVM Fritz!Box routers (read-only)', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'stable' },
|
||||
{ name: 'hpe_officeconnect', deviceType: 'HPE OfficeConnect 1820 / 1920S switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'linux', deviceType: 'Generic Linux servers', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '—', configPush: '—', status: 'stable' },
|
||||
{ name: 'netgear_plus', deviceType: 'Netgear Plus switches (web UI)', facts: '✓', interfaces: '✓', lldp: '—', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'netgear_smart', deviceType: 'Netgear Smart Managed Pro switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'beta' },
|
||||
{ name: 'openmediavault', deviceType: 'OpenMediaVault NAS', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '—', configPush: '—', status: 'stable' },
|
||||
{ name: 'openwrt', deviceType: 'OpenWrt routers and access points', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '✓', health: '✓', docker: '—', reboot: '✓', configPush: '✓', status: 'stable' },
|
||||
{ name: 'opnsense', deviceType: 'OPNsense firewalls', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||
{ name: 'procurve', deviceType: 'HPE ProCurve / Aruba switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||
{ name: 'proxmox', deviceType: 'Proxmox VE hypervisors', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '✓', status: 'stable' },
|
||||
{ name: 'qnap_qts', deviceType: 'QNAP NAS on QTS', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '—', docker: '✓', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'sonos', deviceType: 'Sonos speakers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'tplink_jetstream', deviceType: 'TP-Link JetStream switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||
{ name: 'yealink', deviceType: 'Yealink IP phones', facts: '✓', interfaces: '—', lldp: '—', vlans: '—', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'zyxel', deviceType: 'Zyxel VMG routers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
]
|
||||
|
||||
const NAPALM_BUILTIN = ['Arista EOS', 'Cisco IOS', 'Cisco IOS-XE', 'Cisco NX-OS', 'Juniper JunOS']
|
||||
|
||||
const CAPS: { key: keyof Driver; label: string }[] = [
|
||||
{ key: 'facts', label: 'Facts' },
|
||||
{ key: 'interfaces', label: 'Ifaces' },
|
||||
{ key: 'lldp', label: 'LLDP' },
|
||||
{ key: 'vlans', label: 'VLANs' },
|
||||
{ key: 'ssids', label: 'SSIDs' },
|
||||
{ key: 'health', label: 'Health' },
|
||||
{ key: 'docker', label: 'Docker' },
|
||||
{ key: 'reboot', label: 'Reboot' },
|
||||
{ key: 'configPush', label: 'Config push' },
|
||||
]
|
||||
|
||||
function StatusBadge({ status }: { status: 'stable' | 'beta' }) {
|
||||
return (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
status === 'stable'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Drivers() {
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.drivers.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">{linkify(t.drivers.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-16 overflow-x-auto">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4">{t.drivers.customHeading}</h2>
|
||||
<div className="rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900">
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Driver</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Device type</th>
|
||||
{CAPS.map((c) => (
|
||||
<th key={c.key} className="px-3 py-3 text-center font-medium text-slate-500 whitespace-nowrap">
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DRIVERS.map((d) => (
|
||||
<tr key={d.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-sky-400 whitespace-nowrap">{d.name}</td>
|
||||
<td className="px-4 py-3 text-slate-300 whitespace-nowrap">{d.deviceType}</td>
|
||||
{CAPS.map((c) => (
|
||||
<td key={c.key} className="px-3 py-3 text-center">
|
||||
<span className={d[c.key] === '✓' ? 'text-green-400' : 'text-slate-700'}>
|
||||
{d[c.key]}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-3"><StatusBadge status={d.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-1 text-xs text-slate-500">
|
||||
{t.drivers.notes.map((n) => <li key={n}>{linkify(n)}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4">{t.drivers.napalmHeading}</h2>
|
||||
<p className="text-sm text-slate-400 mb-6">{linkify(t.drivers.napalmSub)}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{NAPALM_BUILTIN.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-slate-700 bg-slate-900 text-xs font-medium text-slate-300"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
function Section({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4 pb-2 border-b border-slate-800">
|
||||
{title}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm text-slate-400 leading-relaxed">
|
||||
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||
{linkify(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.features.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">{t.features.sub}</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
{t.features.sections.map((s) => (
|
||||
<Section key={s.title} title={s.title} items={s.items} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ServerStackIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string; drivers: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps', drivers: 'Runs on what you already have' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft', drivers: 'Läuft auf dem, was ihr schon habt' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'A different admin UI per vendor', body: 'OPNsense here, HP ProCurve there, a separate tab for OpenWRT and Proxmox. Every task starts with figuring out which UI to open.' },
|
||||
{ title: 'No single view of what\'s running where', body: 'Vendor, model, firmware, and status per device live in nobody\'s head — or a spreadsheet that\'s already out of date.' },
|
||||
{ title: 'Config changes with no audit trail', body: 'Someone tweaks a firewall rule directly on the device. Nobody documents it. It breaks something three weeks later.' },
|
||||
{ title: 'Manual SSH just to check a VLAN', body: 'Verifying interface status or VLAN membership means logging into the device directly, every time.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Für jeden Hersteller eine andere Oberfläche', body: 'OPNsense hier, HP ProCurve dort, ein eigener Tab für OpenWRT und Proxmox. Jede Aufgabe beginnt damit, herauszufinden, welche UI überhaupt zuständig ist.' },
|
||||
{ title: 'Keine einzige Ansicht, was wo läuft', body: 'Hersteller, Modell, Firmware und Status pro Gerät stehen in niemandes Kopf — oder in einer Tabelle, die längst veraltet ist.' },
|
||||
{ title: 'Konfigurationsänderungen ohne Audit-Trail', body: 'Jemand ändert eine Firewall-Regel direkt am Gerät. Niemand dokumentiert es. Drei Wochen später bricht etwas.' },
|
||||
{ title: 'Manuelles SSH nur um ein VLAN zu prüfen', body: 'Um Interface-Status oder VLAN-Zugehörigkeit zu verifizieren, muss man sich jedes Mal direkt am Gerät einloggen.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Device Management', body: 'CRUD for every device with credential profiles and SSH key management. Vendor, model, and OS auto-populate from NAPALM get_facts() — no manual data entry.' },
|
||||
{ title: 'Config drift detection & one-click fix', body: 'Every poll compares device state against the desired state in netOrk. Drifted devices get a warning; fixing it streams live SSH output straight to the browser.' },
|
||||
{ title: 'Git-backed config history', body: 'Every poll snapshots the config into a local Git repository — full history, side-by-side diffs between any two points in time, and one-click restore for OPNsense.' },
|
||||
{ title: 'Ansible automation', body: '16 built-in roles (base, docker, adguard, wireguard, fail2ban, a mail server, and more) with automatic dependency resolution. Write your own roles too, with full run history per device.' },
|
||||
{ title: 'VM Provisioning', body: 'Cloud-Init VMs on Proxmox straight from the hypervisor\'s VMs tab — pick an image, a VLAN, and an IP, and netOrk handles the DHCP reservation and Device linking.' },
|
||||
{ title: 'Dashboards', body: 'Configurable, shareable dashboards built from 18 widgets on a WYSIWYG grid — replace the fixed layout with the view your team actually needs.' },
|
||||
{ title: 'Security assessment per device', body: 'TLS and SSH graded A to F, installed software and container images matched against known vulnerabilities, hardening benchmarks, and which networks can reach the device — without an agent on it.' },
|
||||
{ title: 'Vulnerability triage with owners', body: 'One queue across all devices, ordered by what is exploited and overdue. Every decision needs a reason; accepting a risk needs a permission operators do not have, and fixes are verified by the next assessments.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Geräteverwaltung', body: 'CRUD für jedes Gerät mit Credential-Profilen und SSH-Schlüsselverwaltung. Hersteller, Modell und OS werden automatisch über NAPALM get_facts() befüllt — keine manuelle Eingabe.' },
|
||||
{ title: 'Konfigurationsdrift-Erkennung & Ein-Klick-Fix', body: 'Bei jedem Poll wird der Gerätezustand mit dem Sollzustand in netOrk verglichen. Abweichende Geräte erhalten eine Warnung; der Fix streamt Live-SSH-Output direkt in den Browser.' },
|
||||
{ title: 'Git-basierte Konfigurationshistorie', body: 'Bei jedem Poll wird die Konfiguration in ein lokales Git-Repository gesnapshottet — vollständige Historie, Side-by-Side-Diffs zwischen beliebigen Zeitpunkten und Ein-Klick-Restore für OPNsense.' },
|
||||
{ title: 'Ansible-Automatisierung', body: '16 eingebaute Rollen (base, docker, adguard, wireguard, fail2ban, ein Mailserver und mehr) mit automatischer Abhängigkeitsauflösung. Eigene Rollen schreiben inklusive vollständiger Lauf-Historie pro Gerät.' },
|
||||
{ title: 'VM-Provisioning', body: 'Cloud-Init-VMs auf Proxmox direkt aus dem VMs-Tab des Hypervisors — Image, VLAN und IP auswählen, netOrk übernimmt DHCP-Reservierung und Geräteverknüpfung.' },
|
||||
{ title: 'Dashboards', body: 'Konfigurierbare, teilbare Dashboards aus 18 Widgets auf einem WYSIWYG-Grid — statt festem Layout die Ansicht, die euer Team wirklich braucht.' },
|
||||
{ title: 'Sicherheitsbewertung pro Gerät', body: 'TLS und SSH mit Note A bis F, installierte Software und Container-Images gegen bekannte Schwachstellen abgeglichen, Härtungs-Benchmarks, und welche Netze das Gerät erreichen — ohne Agent darauf.' },
|
||||
{ title: 'Schwachstellen-Triage mit Verantwortlichen', body: 'Eine Queue über alle Geräte, geordnet nach Ausgenutztem und Überfälligem. Jede Entscheidung braucht eine Begründung; ein Risiko zu akzeptieren braucht eine Berechtigung, die Operatoren nicht haben, und Behebungen verifizieren die nächsten Bewertungen.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForItDepartment() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<ServerStackIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItDepartment.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forItDepartment.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4 pb-2 border-b border-slate-800">
|
||||
{labels.drivers}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-4">
|
||||
{lang === 'en'
|
||||
? 'Custom NAPALM drivers for 15 device types, plus the built-in NAPALM drivers.'
|
||||
: 'Eigene NAPALM-Treiber für 15 Gerätetypen, plus die integrierten NAPALM-Treiber.'}
|
||||
</p>
|
||||
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{lang === 'en' ? 'Full driver reference →' : 'Vollständige Treiberreferenz →'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forItDepartment.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forItDepartment.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forItDepartment.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { LifebuoyIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Is this device actually up right now?', body: 'Checking status means opening the vendor UI for that one device — or pinging it yourself and hoping.' },
|
||||
{ title: 'The same manual reboot, over and over', body: 'A flaky AP needs a power cycle every few weeks, and someone has to remember to do it and be there for it.' },
|
||||
{ title: '"Who changed this?" — with no record', body: 'A setting is different than it was yesterday. Nobody can say who touched it, when, or why.' },
|
||||
{ title: 'Full admin access just to fix one ticket', body: 'Resolving a single warning shouldn\'t require the keys to the whole network configuration.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Läuft das Gerät gerade wirklich?', body: 'Status prüfen heißt: die Hersteller-UI für genau dieses Gerät öffnen — oder selbst pingen und hoffen.' },
|
||||
{ title: 'Derselbe manuelle Reboot, immer wieder', body: 'Ein zickiger AP braucht alle paar Wochen einen Power-Cycle — und jemand muss daran denken und dafür vor Ort sein.' },
|
||||
{ title: '„Wer hat das geändert?" — ohne Protokoll', body: 'Eine Einstellung ist anders als gestern. Niemand kann sagen, wer sie wann und warum geändert hat.' },
|
||||
{ title: 'Voller Admin-Zugriff nur für ein Ticket', body: 'Eine einzelne Warnung zu beheben sollte nicht den Schlüssel zur gesamten Netzwerkkonfiguration erfordern.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Status at a glance', body: 'Per-device warning system with severity levels (error / warning / info); a dashboard warnings widget surfaces every open issue across sites without hunting per device.' },
|
||||
{ title: 'One-click Ack', body: 'Clears a warning immediately and writes an audit log entry. Config-change warnings accept the current state as the new baseline in the same click.' },
|
||||
{ title: 'Wake-on-LAN', body: 'Saved WOL targets per device, sent via the firewall\'s driver. On-demand "Wake now" or a recurring schedule; save a seen host as a target directly from the DHCP/ARP tabs.' },
|
||||
{ title: 'Scheduled reboots & updates', body: 'Scheduled reboots for OpenWRT APs with per-site concurrency locking, and package updates scheduled or applied with one click.' },
|
||||
{ title: 'Full audit log, filterable', body: 'Every action — who, when, what — filterable by date range, user, action, or resource, exportable to CSV or PDF.' },
|
||||
{ title: 'Roles scoped to the job', body: 'Viewer/operator roles, or a custom permission set, hand out exactly the access support work needs — without granting engineer-level config rights.' },
|
||||
{ title: 'Problems reach you on Signal', body: 'Failed devices, dead tunnels, expiring certificates — one message per outage instead of one per device, quiet hours per person, and any kind of message muted with one click.' },
|
||||
{ title: '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 — a dead resolver shows up in minutes, not at the next ticket.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Status auf einen Blick', body: 'Gerätespezifisches Warnsystem mit Schweregraden (Fehler / Warnung / Info); ein Dashboard-Warnungs-Widget zeigt jedes offene Problem standortübergreifend, ohne pro Gerät zu suchen.' },
|
||||
{ title: 'Ein-Klick-Ack', body: 'Löscht eine Warnung sofort und protokolliert die Aktion im Audit-Log. Config-Change-Warnungen akzeptieren im selben Klick den aktuellen Zustand als neue Baseline.' },
|
||||
{ title: 'Wake-on-LAN', body: 'Gespeicherte WOL-Ziele pro Gerät, gesendet über den Treiber der Firewall. Ein-Klick-„Jetzt wecken" oder wiederkehrender Zeitplan; ein gesehener Host lässt sich direkt aus den DHCP-/ARP-Tabs als Ziel speichern.' },
|
||||
{ title: 'Geplante Reboots & Updates', body: 'Geplante Neustarts für OpenWRT-APs mit standortbezogener Concurrency-Sperre, Paket-Updates geplant oder per Ein-Klick angewendet.' },
|
||||
{ title: 'Vollständiges, filterbares Audit-Log', body: 'Jede Aktion — wer, wann, was — filterbar nach Datumsbereich, Benutzer, Aktion oder Ressource, exportierbar als CSV oder PDF.' },
|
||||
{ title: 'Rollen passend zur Aufgabe', body: 'Betrachter-/Operator-Rollen oder ein eigener Berechtigungssatz geben genau den Zugriff, den Support-Arbeit braucht — ohne Engineer-Rechte für die Konfiguration.' },
|
||||
{ title: 'Probleme kommen per Signal', body: 'Ausgefallene Geräte, tote Tunnel, ablaufende Zertifikate — eine Nachricht pro Ausfall statt einer pro Gerät, Ruhezeiten pro Person, und jede Nachrichtenart mit einem Klick stumm.' },
|
||||
{ title: '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ß — ein toter Resolver fällt in Minuten auf, nicht erst beim nächsten Ticket.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForItSupport() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<LifebuoyIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItSupport.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forItSupport.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forItSupport.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forItSupport.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forItSupport.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Client sites you can\'t reach directly', body: 'Firewalled or disconnected networks mean juggling a VPN per client just to check a device.' },
|
||||
{ title: 'No unified view across clients', body: 'Each client\'s network lives in its own silo — there\'s no single dashboard across every site you manage.' },
|
||||
{ title: 'Proving what you did, and when', body: 'A client asks what changed after an incident, and there\'s no clean record to hand over — just memory and Slack messages.' },
|
||||
{ title: 'Client data in someone else\'s cloud', body: 'SaaS network-management tools mean client infrastructure data sits on a vendor\'s servers you don\'t control.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Kundenstandorte, die ihr nicht direkt erreicht', body: 'Firewalled oder getrennte Netzwerke bedeuten VPN-Jonglieren pro Kunde, nur um ein Gerät zu prüfen.' },
|
||||
{ title: 'Keine einheitliche Sicht über alle Kunden', body: 'Jedes Kundennetzwerk lebt in seiner eigenen Silo — kein einziges Dashboard über alle betreuten Standorte.' },
|
||||
{ title: 'Belegen, was ihr wann gemacht habt', body: 'Ein Kunde fragt nach einem Vorfall, was sich geändert hat — und es gibt kein sauberes Protokoll, nur Erinnerung und Slack-Nachrichten.' },
|
||||
{ title: 'Kundendaten in fremder Cloud', body: 'SaaS-Netzwerk-Tools bedeuten, dass die Infrastrukturdaten eurer Kunden auf Servern eines Anbieters liegen, den ihr nicht kontrolliert.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Satellite Deployments', body: 'A lightweight Docker agent deployed at a client site netOrk can\'t reach directly — polls devices locally and syncs results back to Central over HTTPS. Deployed in one flow via VM Provisioning.' },
|
||||
{ title: 'Central auto-routes around unreachable sites', body: 'Once a satellite is online and heartbeating, Central automatically skips direct polling for that site — no manual toggling per client.' },
|
||||
{ title: 'Actions work the same everywhere', body: 'Scheduled/on-demand reboots, the SNMP auto-fix flow, and discovery jobs all route through the same command channel, direct or behind a satellite. SNMP health-metric polling and WebSSH still need direct reach today.' },
|
||||
{ title: 'A real audit trail to hand a client', body: 'Every orchestration action is logged — who, what, when — filterable and exportable to CSV or PDF. Evidence, not a verbal assurance.' },
|
||||
{ title: 'Roles scoped per technician', body: 'Custom roles control exactly what each technician can do, from read-only visibility to full config access, per engagement.' },
|
||||
{ title: 'Self-hosted, no per-seat SaaS', body: 'Docker Compose deployment, no telemetry, no cloud dependency — runs on your infrastructure or a client\'s, not a vendor\'s.' },
|
||||
{ title: 'A dropped client site is one warning', body: 'When the tunnel to a site goes down, netOrk pauses polling there instead of turning every device red, names the tunnel, and re-polls everything the moment it is back.' },
|
||||
{ title: 'Checks from inside the client network', body: 'Service checks run from the site satellite, so DNS, time servers and gateways are tested from where the client sits — including a DHCP check that only works on the local network.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Satellite-Deployments', body: 'Ein leichtgewichtiger Docker-Agent an einem Kundenstandort, den netOrk nicht direkt erreicht — pollt Geräte lokal und synct Ergebnisse per HTTPS zurück an Central. In einem Ablauf per VM-Provisioning deployt.' },
|
||||
{ title: 'Central umgeht nicht erreichbare Standorte automatisch', body: 'Sobald ein Satellite online ist und heartbeatet, überspringt Central automatisch das direkte Polling für diesen Standort — kein manuelles Umschalten pro Kunde.' },
|
||||
{ title: 'Aktionen funktionieren überall gleich', body: 'Geplante/On-Demand-Reboots, der SNMP-Auto-Fix und Discovery-Jobs laufen über denselben Command-Kanal, egal ob direkt oder hinter einem Satellite. SNMP-Health-Metrik-Polling und WebSSH brauchen heute noch direkten Zugriff.' },
|
||||
{ title: 'Ein echter Audit-Trail für den Kunden', body: 'Jede Orchestrierungsaktion wird protokolliert — wer, was, wann — filterbar und exportierbar als CSV oder PDF. Beleg statt mündlicher Zusicherung.' },
|
||||
{ title: 'Rollen pro Techniker', body: 'Benutzerdefinierte Rollen legen genau fest, was jeder Techniker darf — von reinem Lesezugriff bis vollem Konfigurationszugriff, je nach Einsatz.' },
|
||||
{ title: 'Self-hosted, kein Pro-Seat-SaaS', body: 'Docker-Compose-Deployment, keine Telemetrie, keine Cloud-Abhängigkeit — läuft auf eurer Infrastruktur oder der eines Kunden, nicht bei einem Anbieter.' },
|
||||
{ title: 'Ein ausgefallener Kundenstandort ist eine Warnung', body: 'Fällt der Tunnel zu einem Standort, pausiert netOrk dort das Polling, statt jedes Gerät rot zu färben, nennt den Tunnel und fragt alles sofort neu ab, sobald er zurück ist.' },
|
||||
{ title: 'Checks aus dem Kundennetz heraus', body: 'Service-Checks laufen vom Standort-Satellite, DNS, Zeitserver und Gateways werden also von dort geprüft, wo der Kunde sitzt — inklusive eines DHCP-Checks, der nur im lokalen Netz funktioniert.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForMsp() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<BuildingOffice2Icon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forMsp.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forMsp.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forMsp.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forMsp.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forMsp.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; detail: string; nis2?: boolean }
|
||||
type Group = { label: string; items: Item[] }
|
||||
|
||||
const GROUPS: Record<'en' | 'de', Group[]> = {
|
||||
en: [
|
||||
{
|
||||
label: 'Next release',
|
||||
items: [
|
||||
{
|
||||
title: 'CrowdSec across sites',
|
||||
detail: 'The CrowdSec plugin grows into its own section: every LAPI instance, decisions and alerts across sites, how many sites one address reached, bans inside your own subnets counted separately, and which internet-facing hosts nobody watches yet.',
|
||||
},
|
||||
{
|
||||
title: 'Windows driver',
|
||||
detail: 'Windows hosts over WinRM: facts, interfaces, ARP, routes and services, including service control.',
|
||||
},
|
||||
{
|
||||
title: 'Single-use console tickets',
|
||||
detail: 'The browser terminal opens with a one-time ticket instead of passing the session token in the WebSocket URL.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Honest reboots',
|
||||
detail: 'A reboot request for a device whose driver cannot restart it is refused with a reason instead of being reported as done.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Planned',
|
||||
items: [
|
||||
{
|
||||
title: 'Compliance dashboard',
|
||||
detail: 'Per-site Art. 21 checklist: asset coverage, patch status, security agent deployment, config drift, syslog forwarding, audit activity — aggregated into a single view.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Webhook engine',
|
||||
detail: 'Outbound HTTP webhooks for events: device discovered, config change detected, job failed, alert threshold exceeded. HMAC-SHA256 payload signing. Integrates with n8n, Slack, PagerDuty.',
|
||||
},
|
||||
{
|
||||
title: 'Live job log streaming',
|
||||
detail: 'WebSocket endpoint for real-time Celery task output in the UI. Currently streamed via SSE for fix-flows; full job log streaming for all long-running operations.',
|
||||
},
|
||||
{
|
||||
title: 'NetBox sync — manual trigger & status view',
|
||||
detail: 'POST /api/v1/netbox/sync endpoint with progress feedback and a sync history view in the UI. Currently sync runs automatically; the manual trigger and status are missing.',
|
||||
},
|
||||
{
|
||||
title: 'HashiCorp Vault integration',
|
||||
detail: 'Real secret management as the first security plugin, replacing the current Fernet-based encryption at rest for device credentials and SSH keys.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Firewall profile management — next steps',
|
||||
detail: 'Per-site profiles with a diff against a live OPNsense and step-by-step apply shipped in 0.12. Profile types, OpenWrt as a target, and the push mechanism beyond that are still under review.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Under consideration',
|
||||
items: [
|
||||
{
|
||||
title: 'VLAN visualization',
|
||||
detail: 'Heatmap or matrix view of which devices carry which VLANs, without digging through per-device VLAN lists.',
|
||||
},
|
||||
{
|
||||
title: 'Incident workflow',
|
||||
detail: 'Structured incident record tied to devices and security events. Deadline tracker for NIS2 Art. 23 reporting windows (24 h early warning, 72 h full notification). Webhook to external ticketing systems.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'mDNS scanner',
|
||||
detail: 'Discover media devices (Apple TV, Chromecast, Sonos) via mDNS/Bonjour without needing a NAPALM driver. Inventory visibility and firewall segmentation suggestions.',
|
||||
},
|
||||
{
|
||||
title: 'Prometheus metrics + Grafana dashboards',
|
||||
detail: 'Expose per-device SNMP health metrics as a Prometheus scrape endpoint. Pre-built Grafana dashboard for interface counters, CPU, memory, and poll lag.',
|
||||
},
|
||||
{
|
||||
title: 'Kubernetes Helm chart',
|
||||
detail: 'Helm chart for production-grade deployments: horizontal scaling for the API and worker pods, Secrets via Vault Agent Injector, Ingress with TLS termination.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
de: [
|
||||
{
|
||||
label: 'Nächstes Release',
|
||||
items: [
|
||||
{
|
||||
title: 'CrowdSec über alle Standorte',
|
||||
detail: 'Das CrowdSec-Plugin wird ein eigener Bereich: jede LAPI-Instanz, Entscheidungen und Alerts über alle Standorte, wie viele Standorte eine Adresse erreicht hat, Sperren im eigenen Netz getrennt gezählt, und welche vom Internet erreichbaren Hosts noch niemand überwacht.',
|
||||
},
|
||||
{
|
||||
title: 'Windows-Treiber',
|
||||
detail: 'Windows-Hosts über WinRM: Fakten, Interfaces, ARP, Routen und Dienste, inklusive Dienststeuerung.',
|
||||
},
|
||||
{
|
||||
title: 'Einmal-Tickets für die Konsole',
|
||||
detail: 'Das Browser-Terminal öffnet mit einem einmal gültigen Ticket, statt das Sitzungstoken in der WebSocket-URL mitzugeben.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Ehrliche Neustarts',
|
||||
detail: 'Eine Neustart-Anfrage für ein Gerät, dessen Treiber es nicht neu starten kann, wird mit Begründung abgelehnt, statt als erledigt gemeldet zu werden.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Geplant',
|
||||
items: [
|
||||
{
|
||||
title: 'Compliance-Dashboard',
|
||||
detail: 'Art. 21-Checkliste pro Standort: Asset-Abdeckung, Patch-Status, Security-Agent-Deployment, Config-Drift, Syslog-Weiterleitung, Audit-Aktivität — zusammengefasst in einer Ansicht.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Webhook-Engine',
|
||||
detail: 'Ausgehende HTTP-Webhooks für Events: Gerät entdeckt, Konfigurationsänderung erkannt, Job fehlgeschlagen, Warnschwelle überschritten. HMAC-SHA256-Payload-Signierung. Integrierbar mit n8n, Slack, PagerDuty.',
|
||||
},
|
||||
{
|
||||
title: 'Live-Job-Log-Streaming',
|
||||
detail: 'WebSocket-Endpunkt für Echtzeit-Celery-Task-Output in der UI. Aktuell wird via SSE für Fix-Flows gestreamt; vollständiges Job-Log-Streaming für alle lang laufenden Operationen geplant.',
|
||||
},
|
||||
{
|
||||
title: 'NetBox-Sync — manueller Trigger & Statusansicht',
|
||||
detail: 'POST /api/v1/netbox/sync-Endpunkt mit Fortschrittsfeedback und Sync-Verlaufsansicht in der UI. Derzeit läuft der Sync automatisch; manueller Trigger und Status fehlen noch.',
|
||||
},
|
||||
{
|
||||
title: 'HashiCorp-Vault-Integration',
|
||||
detail: 'Echtes Secret-Management als erstes Security-Plugin — löst die aktuelle Fernet-basierte Verschlüsselung von Geräte-Credentials und SSH-Schlüsseln ab.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Firewall-Profile — nächste Schritte',
|
||||
detail: 'Profile pro Standort mit Diff gegen eine echte OPNsense und schrittweisem Anwenden kamen mit 0.12. Profiltypen, OpenWrt als Ziel und der Push-Mechanismus darüber hinaus stehen noch auf dem Prüfstand.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'In Erwägung',
|
||||
items: [
|
||||
{
|
||||
title: 'VLAN-Visualisierung',
|
||||
detail: 'Heatmap- oder Matrixansicht, welche Geräte welche VLANs führen — ohne sich durch geräteweise VLAN-Listen zu graben.',
|
||||
},
|
||||
{
|
||||
title: 'Incident-Workflow',
|
||||
detail: 'Strukturierter Incident-Datensatz, verknüpft mit Geräten und Sicherheitsereignissen. Fristen-Tracker für NIS2 Art. 23 Meldepflichten (24 h Frühwarnung, 72 h vollständige Meldung). Webhook zu externen Ticketing-Systemen.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'mDNS-Scanner',
|
||||
detail: 'Entdeckt Mediengeräte (Apple TV, Chromecast, Sonos) über mDNS/Bonjour ohne NAPALM-Treiber. Inventarsichtbarkeit und Empfehlungen zur Firewall-Segmentierung.',
|
||||
},
|
||||
{
|
||||
title: 'Prometheus-Metriken + Grafana-Dashboards',
|
||||
detail: 'Veröffentlicht gerätebezogene SNMP-Gesundheitsmetriken als Prometheus-Scrape-Endpunkt. Vorgefertigtes Grafana-Dashboard für Schnittstellenzähler, CPU, Speicher und Poll-Lag.',
|
||||
},
|
||||
{
|
||||
title: 'Kubernetes Helm Chart',
|
||||
detail: 'Helm Chart für produktionsreife Deployments: horizontale Skalierung für API- und Worker-Pods, Secrets via Vault Agent Injector, Ingress mit TLS-Terminierung.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function Nis2Badge() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-mono font-semibold bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||
NIS2
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Roadmap() {
|
||||
const { lang, t } = useLang()
|
||||
const groups = GROUPS[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.roadmap.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">
|
||||
{t.roadmap.sub.slice(0, t.roadmap.sub.indexOf('NIS2'))}
|
||||
<Nis2Badge />
|
||||
{t.roadmap.sub.slice(t.roadmap.sub.indexOf('NIS2') + 'NIS2'.length)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-14">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{group.label}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.title} className="flex gap-4">
|
||||
<div className="mt-2 shrink-0 h-1.5 w-1.5 rounded-full bg-sky-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-slate-200">{item.title}</span>
|
||||
{item.nis2 && <Nis2Badge />}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(item.detail)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user