10 Commits
Author SHA1 Message Date
christianmanivong 992df0e6b1 Merge pull request 'Redesign: control instead of drift' (#3) from feature/redesign into main
CI / TypeScript — type-check (push) Successful in 19s
CI / Publish — build & push image (push) Successful in 27s
2026-09-30 05:44:34 +00:00
Christian ManivongandClaude Opus 5.5 f70fec496a feat: redesign — one message, one audience, light pages
CI / TypeScript — type-check (push) Successful in 18s
CI / Publish — build & push image (push) Skipped
CI / TypeScript — type-check (pull_request) Successful in 17s
CI / Publish — build & push image (pull_request) Skipped
The site felt old, unfocused and bloated: 11 pages, a features page of 171
bullets, a homepage of 830 words in card grids around seven mockup-style
screenshots, and no single thing it wanted a visitor to understand.

- Message: control instead of drift. Audience: IT departments in small and
  mid-sized companies. Goal: buy a licence — netOrk itself is free, the
  licence adds vulnerability data and image updates.
- Home is under 400 words: the drift comparison of a real access point, three
  steps, the hardware it runs on, vulnerabilities with a licence, what else is
  in the box, one closing band. Features, Drivers and Roadmap are gone; their
  URLs redirect (router and nginx 301).
- New Pricing page: the free core, Starter / Pro / Enterprise on request with
  the plan differences from the licence server, four questions; buttons go to
  the licence portal. The unit-less KB request limit is left out.
- Persona pages are one template; NIS2 and Plugins are cut to half or less.
  Impressum and Datenschutz exist as marked placeholders; the unsupported
  "MIT licence" claim is gone from the footer.
- Look: light paper and ink, the dark product on a stage, Inter self-hosted,
  split sections and ruled lists instead of cards. Four real, cropped
  screenshots replace eight full-window ones.
- Language follows the browser until someone chooses; <html lang> is set.
  Scroll-to-top on navigation, a catch-all route, no dead /docs/architecture.
- CLAUDE.md, DESIGN.md, PAGES.md and PRODUCT.md describe the new rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:24:49 +02:00
Christian ManivongandClaude Opus 5.5 369f66afdc 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>
2026-09-29 23:24:49 +02:00
christianmanivong d585f9272f Merge pull request 'Website: netOrk v0.28.0 and real screenshots' (#2) from feature/v0.28-real-screenshots into main
CI / TypeScript — type-check (push) Successful in 17s
CI / Publish — build & push image (push) Successful in 30s
2026-09-26 08:58:00 +00:00
Christian ManivongandClaude Opus 5.5 8a1cf7b2b6 fix: usable on phones — menu, no sideways scrolling, whole roadmap intro
CI / TypeScript — type-check (push) Successful in 19s
CI / Publish — build & push image (push) Skipped
CI / TypeScript — type-check (pull_request) Successful in 17s
CI / Publish — build & push image (pull_request) Skipped
- The navigation had no mobile layout: every page was 413px wider than a
  phone. Below lg the links now sit behind a menu button, grouped like the
  desktop dropdowns; the menu closes on navigation.
- Glossary tooltips were invisible but still laid out, so a term near the
  right edge widened the page. Hidden tooltips no longer take up space;
  they show on hover and on keyboard focus as before.
- Long German words in page headings ("produktionstauglich",
  "Konfigurationstiefe") overflowed at 360px; headings start a size smaller
  on phones.
- The roadmap intro dropped everything after its second "NIS2" ("…the
  baseline requirements of"); it is split at the first one only now.

Checked at 360, 390 and 768px on every page: no horizontal overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:52:13 +02:00
Christian ManivongandClaude Opus 5.5 011f816fc9 feat: replace UI mockups with real netOrk screenshots
The homepage showed seven hand-built JSX imitations of the netOrk UI. They
are gone; every image is now a screenshot of netOrk v0.28.0 itself, taken
from an anonymized copy of a production database (scripts/demo) with
scripts/screenshots/capture.py and published as WebP (~630 KB for all eight).

- Hero: the device inventory. Walkthrough: device detail, VLANs, the Security
  tab, the vulnerability triage queue (replacing the config-diff row), the
  dashboard and service checks (new row 6). NIS2: the audit log, filtered to
  what people did.
- Copy follows the images: row 3 describes the security assessment, row 4 the
  triage queue; row 2 no longer claims corrections are always automatic;
  18 widgets. Alt texts in both languages.
- Also fixed on the homepage: the NIS2 teaser for Art. 21 (2e) and the
  container list of a deployment (three worker pools, plus Flower, registry,
  APT cache and the Signal gateway).
- Demo tooling hardened on the real dump: secrets inside JSON (Wi-Fi keys),
  reverse DNS zones, glued identifiers, tens of thousands of CrowdSec
  addresses, a schema newer than the release (anonymize, then downgrade),
  MFA-enforcing roles, and click steps for view filters.
- DESIGN.md: real screenshots only. PAGES.md: the six rows as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 08:24:51 +02:00
Christian ManivongandClaude Opus 5.5 38834100d1 feat(screenshots): real netOrk screenshots from an anonymized demo copy
The site has shown hand-built JSX mockups of the UI so far. This adds the
tooling to replace them with screenshots of the real application:

- scripts/demo/up.sh restores a pg_dump of a production database into a
  local Postgres and starts netOrk (a pinned release, default v0.28.0) with
  only the API and the UI: no worker, no beat, no Redis, a random encryption
  key. Nothing polls and nothing can reach a device.
- scripts/demo/anonymize.py rewrites every text, JSON and address column of
  every table: domains to example.demo, private IPv4 per /16 with the host
  part kept, public addresses into the documentation ranges, MACs with the
  vendor prefix kept, e-mail addresses and configured names. Secrets are
  emptied by column name, one admin "netork" is left. It refuses non-local
  databases and ends with a leak report. The real-to-demo name map lives
  outside the repo.
- scripts/screenshots/capture.py drives headless Chromium through a
  declarative list of pages, logs in to the demo copy by itself, and aborts
  every non-GET API request, so taking screenshots cannot change anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:33:18 +02:00
Christian ManivongandClaude Opus 5.5 4cba6e156c feat: reflect netOrk v0.12.0–v0.28.0 release notes
Seventeen releases since the site was last brought up to date, checked
against the changelog and the code at the v0.28.0 tag.

- New feature sections: Security Assessment (TLS/SSH grades, CVE and
  container-image matching, exposure, deep scans; Knowledge Base licence),
  Vulnerability Management (triage queue, decisions with reasons, deferrals
  that come back, verified fixes), DHCP, Managed Services, Notifications
  (Signal).
- Existing sections gain per-user SSH keys and session windows, multi-role
  devices, one device per address per site, LAN Scan, MAC-table topology,
  service checks, site reachability, per-site firewall profiles with diff,
  honoured drift auto-correct, 16 Ansible roles, 18 dashboard widgets.
- Corrections: Docker status is Linux/OMV/QNAP, not Proxmox.
- Roadmap: CVE tracking shipped and is gone from "Planned"; a "Next release"
  group lists what is on main but unreleased (CrowdSec across sites, Windows
  driver, single-use console tickets, reboots refused instead of faked).
- NIS2: Art. 21 (2e) now describes the vulnerability handling that exists,
  (2i) adds attributable terminal sessions; CVE tracking left "coming".
- Persona pages: two new items each, counts updated. Glossary: Kea, WinRM,
  LAPI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:33:02 +02:00
Christian ManivongandClaude Opus 5.5 04c5c00280 fix: correct the driver matrix against netOrk v0.28.0
Audited the drivers that actually ship in v0.28.0 (vendor-drivers.txt at the
tag, each driver package at its pin) instead of carrying the old table on.

- Reboot: netOrk's reboot really restarts only OpenWrt and Proxmox. For every
  other driver the request reported success while nothing happened, so the
  column now says so. Rebooting through an update run is mentioned in a note.
- Config push: OPNsense, ProCurve, TP-Link JetStream, Netgear Smart and Proxmox
  do get configuration written by netOrk; the column was missing them.
- Zyxel is a VMG residential gateway, not a switch: no LLDP, VLANs or health,
  but SSIDs. Fritz!Box is read-only and has no health metrics. Proxmox has no
  Docker view.
- netgear is two drivers, netgear_smart and netgear_plus. New: hpe_officeconnect,
  qnap_qts, yealink.
- The built-in NAPALM drivers are installed but untested with netOrk and get
  none of its driver-specific features; the page no longer says "supported".
- Notes explain the Health, LLDP, Config push and Reboot columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 07:28:16 +02:00
christianmanivongandClaude Opus 5 8aa158aa3b docs: Infrastrukturänderungen gehören ins Infrastruktur-Repo — und vorher
CI / TypeScript — type-check (push) Successful in 16s
CI / Publish — build & push image (push) Successful in 22s
Diese Anwendung teilt sich Netz, Datenbankcluster, öffentlichen Eingang und
Backup mit anderen Projekten. Wer dort etwas ändert, ändert es für alle, und
dem eigenen Repo sieht man es nicht an.

Die Regel steht vollständig in christianmanivong/infrastructure; hier nur der
Verweis, damit sie dort gelesen wird, wo gearbeitet wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 15:27:57 +02:00
49 changed files with 2906 additions and 3499 deletions
+7
View File
@@ -8,3 +8,10 @@ memory/
# Deploy target + registry token
deploy.env
# Screenshot tooling: bytecode, and database dumps that hold production data
__pycache__/
*.dump
# Output of scripts/check/site.py
.check/
+45 -13
View File
@@ -10,14 +10,38 @@ get started.
## Project Purpose
Potential users land here and need to answer three questions in under 10 seconds:
1. What is this?
2. Is it for me?
3. How do I try it?
1. What is this? — Control instead of drift: define the desired state once,
netOrk finds every deviation and puts it back.
2. Is it for me? — IT departments in small and mid-sized companies with mixed
hardware (service providers and IT support are secondary).
3. What does it cost? — netOrk is free; a licence adds vulnerability data and
updates (`/pricing`).
Everything on the site should serve those three questions.
---
## Infrastruktur ändert man woanders — und vorher
Diese Anwendung läuft auf einer Infrastruktur, die sie sich mit anderen Projekten teilt:
Netz, Datenbankcluster, öffentlicher Eingang und Backup gehören keinem Projekt allein.
Dokumentiert ist sie in **`git.netork.io/christianmanivong/infrastructure`**, und dort
steht in `CLAUDE.md` auch die verbindliche Regel.
**Kurz: erst dort dokumentieren, ausdrücklich genehmigen lassen, dann ändern.** Nicht
umgekehrt, und „ja mach mal" zu einer früheren Frage deckt die nächste Änderung nicht mit
ab.
Betroffen ist alles, was über dieses Repo hinausreicht — Hosts, Netze, Firewall-Regeln,
der Patroni-Cluster samt `pg_hba` und DCS-Parametern, pgBackRest, BunkerWeb-Hosts, DNS,
CI-Runner, alles, was eine Anwendung auf den geteilten Datenbankcluster umzieht.
**Nicht** betroffen: Anwendungscode, Abhängigkeiten und Migrationen innerhalb der eigenen
Datenbank.
Der Grund für die Reihenfolge ist nicht Bürokratie. Die meisten Zwischenfälle dort waren
nicht falsche Werte, sondern richtige Werte in der falschen Reihenfolge — und das fällt
beim Aufschreiben auf, nicht beim Tippen. Im Zweifel dorthin.
## Content & Design Source of Truth
All product content (features, copy, page structure) is in `docs/`:
@@ -53,13 +77,19 @@ exactly as netOrk's `ui/src/components/ui.tsx` does.
## Design Rules (summary — full detail in docs/DESIGN.md)
- **Dark theme only.** Background `bg-slate-950`. Cards `bg-slate-900`.
- **Accent color:** `sky-500` / `sky-600` for CTAs, links, highlights.
- **No light mode toggle.** Ever.
- Font stack: system default (Tailwind sans). No Google Fonts.
- All interactive elements use `transition-colors` — no layout shifts.
- Screenshots/mockups of the actual app use a `border border-slate-700 rounded-xl
overflow-hidden` wrapper to frame them against the dark background.
- **Light pages, dark product.** Pages are `paper` with `ink`; the only dark
surfaces are real netOrk screenshots and code, on the `night` stage.
- One accent, `accent` (sky-700), for links, eyebrows and focus. Buttons are ink.
- Font: Inter Variable, self-hosted via `@fontsource-variable/inter`. No Google
Fonts, no request to any other origin.
- Layout from `src/components/ui.tsx`: split sections, ruled lists, numbered
steps, bands. No card grids, no icon tiles, no centred text blocks.
- Motion: `transition-colors` only — no layout shifts.
- **Screenshots are real.** Taken from the anonymised demo copy with
`scripts/screenshots/capture.py`, cropped to what the text talks about, at most
four on the site. No mockups, no edited data, no clicks that fake a state.
- **Less text.** Word budgets per page are in docs/PAGES.md and checked by
`scripts/check/site.py`.
---
@@ -87,7 +117,9 @@ netork-website/
## Tone of Voice
- Direct and technical — audience is engineers, not executives.
- Direct and technical — audience is the admins of an IT department, not executives.
- One message: control instead of drift. Everything else supports it.
- No marketing fluff ("revolutionize", "empower", "seamless").
- Show, don't tell — a screenshot or code block beats three sentences of prose.
- German is fine for internal docs; the website copy is in **English**.
- Show, don't tell — a real screenshot beats three sentences of prose.
- Website copy exists in **English and German** (German addresses readers with
"ihr"); the language follows the browser until the visitor chooses.
+59 -279
View File
@@ -1,298 +1,78 @@
# netOrk — Visual Identity & Design System
# netOrk website — design system
This document defines the visual identity of netOrk and must be followed
exactly when building the website. The goal is zero visual discontinuity
between the product UI and the marketing site.
**Light pages, dark product.** The site is calm paper with ink; the only dark
surfaces are real screenshots of netOrk and code, set on a "stage". References:
Linear/Vercel for precision, Tailscale/Netbird for friendliness.
---
Tokens live in `tailwind.config.js`, building blocks in `src/components/ui.tsx`.
If a page needs something that is not there, it probably needs less instead.
## Logo Assets
## Colour
The logo file is in `public/` — use it directly, do not recreate.
| File | Format | Size | Use |
|---|---|---|---|
| `public/logo.png` | PNG | 1024×1024, RGBA | Nav logo, OG image, hero, press kit, favicon fallback |
### Usage in `<img>` (nav, hero)
```tsx
<img src="/logo.png" alt="netOrk" className="h-8 w-8" />
```
For the nav, pair it with the wordmark:
```tsx
<a href="/" className="flex items-center gap-2.5">
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
<span className="font-semibold text-slate-100 tracking-tight">
net<span className="text-sky-400">Ork</span>
</span>
</a>
```
Use `net<span class="text-sky-400">Ork</span>` consistently — the `Ork` part
in sky-400 ties the wordmark to the accent color.
### `<head>` references
```html
<link rel="icon" href="/logo.png" />
<meta property="og:image" content="/logo.png" />
```
---
## Core Principle
**The website looks like a dark-mode dev tool, not a SaaS landing page.**
No gradients, no floating orbs, no animated hero blobs. The aesthetic is
deliberate, minimal, and technical — consistent with the product itself.
---
## Color Palette
All colors are Tailwind CSS v3 classes. Do not use hex values directly —
always use Tailwind class names to stay consistent.
### Backgrounds
| Layer | Class | Usage |
| Token | Value | Use |
|---|---|---|
| Page / outermost | `bg-slate-950` | Body, full-bleed sections |
| Card / panel | `bg-slate-900` | Content cards, code blocks, feature boxes |
| Elevated element | `bg-slate-800` | Hover states, dropdowns, table rows on hover |
| Border | `border-slate-700` | Between sections, card outlines |
| Subtle border | `border-slate-800` | Inside cards, dividers |
| `paper` / `paper-2` | `#FAFAF9` / `#F3F3F0` | page / band |
| `line` / `line-strong` | `#E6E6E3` / `#D4D4D0` | hairlines |
| `ink` / `ink-soft` / `ink-muted` / `ink-faint` | `#0E1116` … `#9AA0A8` | headings / body / secondary / quiet |
| `night` | `#020617` | screenshot and code stage — the netOrk UI's own background |
| `accent` (`hover`, `soft`) | `#0369A1` | links, eyebrows, numbers, focus ring (5.7:1 on paper) |
| `drift` / `sync` | `#B45309` / `#15803D` | status: deviates / matches; never the only signal |
### Text
No gradients, no glow, no second accent. Buttons are ink, not accent.
| Role | Class |
## Type
Inter Variable, self-hosted through `@fontsource-variable/inter` (bundled by
Vite; the site makes no request to anyone else). Scale:
| Class | Use |
|---|---|
| Primary | `text-slate-100` |
| Secondary / muted | `text-slate-400` |
| Tertiary / placeholder | `text-slate-500` |
| Accent (interactive) | `text-sky-400` |
| Danger | `text-red-400` |
| `text-display` | the home headline only |
| `text-h1` | one per page, in `PageHeader` |
| `text-h2` | section headings |
| `text-h3` | row terms, plan names |
| `text-lead` | the paragraph under a heading |
| `text-eyebrow` + `uppercase text-accent` | the small line above a heading |
### Accent (Interactive / CTA)
Everything is left-aligned. Headings balance and hyphenate (`<html lang>` is
set per language). Text columns stay within `max-w-measure` (38rem).
| State | Class |
|---|---|
| Default button | `bg-sky-600 text-white` |
| Hover | `hover:bg-sky-500` |
| Link / inline | `text-sky-400 hover:text-sky-300` |
| Active indicator | `text-sky-400` |
| Focus ring | `focus:ring-sky-500` |
## Layout
### Semantic Colors
- Container `max-w-page` (72rem), `px-5 sm:px-8`. Sections `py-20 md:py-28`.
- **Split**: heading on the left five columns, content on the right. The
default section.
- **RuleList**: rows divided by hairlines, term and body; one or two columns.
This replaces every card grid.
- **Steps**: numbered rows (`01`, `02`, `03`) in mono accent.
- **Band**: `bg-paper-2` with hairlines, for the hardware strip and the closing
call to action (`CtaBand`).
- **Stage**: `bg-night`, `rounded-2xl`, `shadow-stage` — screenshots (`Shot`)
and code (`CodeBlock`).
| Meaning | Color |
|---|---|
| Success / active | `text-green-400`, `bg-green-500/20` |
| Warning / caution | `text-yellow-400`, `bg-yellow-500/20` |
| Danger / error | `text-red-400`, `bg-red-500/20` |
| Info / neutral | `text-blue-400`, `bg-blue-500/20` |
Not used: icon tiles, pills for names, cards, centred text blocks, fake browser
windows, emoji. Motion is `transition-colors` only.
### Status Badge Pattern
## Screenshots
```tsx
// Active / online
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
active
</span>
Only real screenshots of netOrk, from the anonymised demo copy
(`scripts/demo`), taken by `scripts/screenshots/capture.py` and published as
WebP in `public/screenshots/`. Their sizes are written to
`src/data/screenshots.json`, which `Shot` reads.
// Offline
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-red-500/20 text-red-400">
offline
</span>
- At most four different images on the site. Each is cropped to the one thing
the text next to it talks about (`clip` or `element` in `shots.py`).
- Cropping shows less of a real screen; it never changes what is on it. No
edited data, no clicks that fake a state, no mockups.
- A crop that does not read on a phone gets a `-narrow` variant (`Shot narrow=`).
- Every image has an alt text and a caption that says what is true in it.
// Warning
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">
warning
</span>
```
## Wordmark
---
Text only: `net<span class="text-accent">Ork</span>` in ink. `public/logo.png`
is the favicon and OG image; it does not sit well on a light background.
## Typography
## Glossary marks
Font stack: Tailwind default sans-serif (`font-sans`). **No Google Fonts.**
The product uses system fonts; the website must match.
| Element | Classes |
|---|---|
| Hero heading | `text-4xl md:text-6xl font-bold text-slate-100 leading-tight` |
| Section heading | `text-2xl md:text-3xl font-bold text-slate-100` |
| Subsection heading | `text-xl font-semibold text-slate-200` |
| Body text | `text-base text-slate-400 leading-relaxed` |
| Small / label | `text-sm text-slate-400` |
| Tiny / tag | `text-xs font-medium text-slate-500` |
| Code / monospace | `font-mono text-sky-400` |
| Accent text | `text-sky-400` |
---
## Spacing & Layout
- Max content width: `max-w-7xl mx-auto px-6`
- Section padding: `py-24` (desktop), `py-16` (mobile)
- Card padding: `p-6`
- Gap between grid items: `gap-6` or `gap-8`
- All layouts are mobile-first; use `md:` and `lg:` breakpoints.
---
## Components
### Primary CTA Button
```tsx
<a
href="/docs/getting-started"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg
bg-sky-600 hover:bg-sky-500 text-white font-medium
transition-colors"
>
Get started
</a>
```
### Secondary / Ghost Button
```tsx
<a
href="/features"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg
border border-slate-700 hover:border-slate-500
text-slate-300 hover:text-slate-100
transition-colors"
>
See all features
</a>
```
### Feature Card
```tsx
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
<div className="mb-4 flex h-10 w-10 items-center justify-center
rounded-lg bg-sky-600/10">
{/* Heroicon SVG, className="h-5 w-5 text-sky-400" */}
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-100">Feature name</h3>
<p className="text-sm text-slate-400 leading-relaxed">
Description of the feature in one to three sentences.
</p>
</div>
```
### Screenshot Frame
App screenshots must be wrapped in this frame to integrate naturally
against the dark background:
```tsx
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
<img src="/screenshots/device-list.png" alt="Device inventory" className="w-full" />
</div>
```
Optionally add a browser chrome header above the image:
```tsx
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
<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-green-500/70" />
<span className="ml-4 text-xs text-slate-500 font-mono">netork.local</span>
</div>
```
### Code Block
```tsx
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6
font-mono text-sm text-slate-300 overflow-x-auto">
<code>{`bash scripts/deploy.sh 192.168.1.1`}</code>
</pre>
```
### Driver / Integration Badge
```tsx
<span 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">
OpenWRT
</span>
```
### Section Divider
```tsx
<div className="border-t border-slate-800" />
```
---
## Navigation
- Sticky top nav: `sticky top-0 z-10 bg-slate-900/80 backdrop-blur
border-b border-slate-800`
- Logo: left-aligned. Product name in `font-semibold text-slate-100`,
optionally prefixed with a small icon.
- Nav links: `text-sm text-slate-400 hover:text-slate-100 transition-colors`
- Active link: `text-slate-100`
- CTA in nav: small primary button `px-4 py-1.5 text-sm`
---
## Animations & Transitions
- **Hover states:** always `transition-colors` (not `transition-all`).
- **No JavaScript animations** on initial page load — no entrance animations,
no scroll-triggered reveals via IntersectionObserver.
- Scroll behavior: `scroll-smooth` on `<html>` for anchor links.
- No parallax, no floating elements, no auto-playing videos.
---
## Logo / Wordmark
The netOrk wordmark uses the following convention in the product:
- Lowercase `n`, uppercase `O`: **netOrk**
- Monospace context: `font-mono text-sky-400`
- Heading context: `font-bold text-slate-100` with `Ork` potentially in
`text-sky-400` if desired for emphasis
---
## Iconography
Use Heroicons (inline SVG). Sizes:
- Feature card icons: `h-5 w-5`
- Nav / button icons: `h-4 w-4`
- Hero / large decorative: `h-8 w-8` or `h-10 w-10`
All icons: `text-sky-400` in feature contexts, `text-slate-400` in
secondary/muted contexts.
---
## tailwind.config.js
No custom theme extensions needed. The default Tailwind v3 slate + sky
palette covers everything. The config only needs content paths:
```js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
```
Terms from `src/glossary/terms.ts` get a dotted underline and a dark tooltip
(`linkify`). Not on the homepage — short copy there stays unmarked.
+78 -461
View File
@@ -1,466 +1,83 @@
# netOrk Website — Page Structure & Content Plan
# netOrk website — pages
This document defines every page of the website: its purpose, section
structure, and draft copy. Use this as the brief for implementation.
**Audience:** IT departments in small and mid-sized companies — a small team,
mixed hardware (OPNsense, HPE ProCurve/Aruba, TP-Link JetStream, OpenWrt,
Proxmox, Linux), NIS2 on the agenda. Service providers and IT support are
secondary and get their own page each.
---
**One message:** control instead of drift — define the desired state once;
netOrk notices every change, shows what deviates and puts it back.
## Page Overview
**One goal:** buy a licence. netOrk itself is free; the licence adds
vulnerability data and image updates.
| Route | Page | Priority |
All copy is in `src/i18n/translations.ts`, English and German (German uses
"ihr"). The language follows the browser until someone chooses. Every claim
must be backed by `docs/PRODUCT.md`; automatic fixing exists for access point
profiles, so the site says "every deviation", never "every device fixes itself".
## Word budgets (English, text in `<main>`)
`scripts/check/site.py` counts them; Home over budget fails the check.
| Page | Route | Budget |
|---|---|---|
| `/` | Landing (Home) | P0 — build first |
| `/features` | Full feature list | P1 |
| `/drivers` | Supported devices | P1 |
| `/docs/getting-started` | Installation guide | P1 |
| `/roadmap` | Roadmap — planned + under consideration | P1 |
| `/nis2` | NIS2 landing page — Art. 21 mapping, evidence, roadmap | P1 |
| `/docs/architecture` | Technical overview | P2 |
| `/plugins` | Plugin system | P2 |
---
## `/` — Landing Page
### Section 1 — Hero
**Purpose:** Answer "what is this?" in 5 seconds.
**Layout:** Full-width, centered. Heading + subheading + two CTAs + hero
screenshot below.
**Heading:**
```
Network orchestration
for heterogeneous infrastructure.
```
(`text-slate-100` for first line, second line in `text-sky-400` or keep
both `text-slate-100` — designer decides.)
**Subheading:**
```
netOrk discovers, monitors, and manages your routers, switches, access
points, firewalls, and servers from a single UI — regardless of vendor.
No SaaS dependency. Runs on your infrastructure.
```
**CTAs:**
- Primary: `Get started →` → `/docs/getting-started`
- Secondary: `View features` → `/features`
**Hero visual:** Full-width screenshot of the device inventory page
(dark UI visible, framed with the browser chrome component from DESIGN.md).
---
### Section 2 — Problem Statement
**Purpose:** Make the pain relatable.
**Layout:** Single centered paragraph or short 3-column stat row.
**Copy:**
```
Managing a mixed network means juggling a different admin UI for every
vendor — one for OPNsense, one for HP ProCurve, one for OpenWRT, one for
Proxmox. Config changes happen directly on devices with no audit trail.
You find out something drifted when it breaks.
```
---
### Section 3 — Core Capabilities (3-up)
**Purpose:** Communicate the three main things netOrk does.
**Layout:** 3 columns, each with icon + heading + 2–3 sentences.
**Card 1 — Discover & Inventory**
- Icon: `MagnifyingGlassIcon`
- Heading: `Discover everything on your network`
- Copy: `ICMP sweep, SNMP scan, and HTTP probing find devices before you
add them. Fingerprinting identifies vendor and platform automatically.
Adopt results into your inventory with a single click.`
**Card 2 — Monitor & Alert**
- Icon: `ChartBarIcon` or `SignalIcon`
- Heading: `Poll device state continuously`
- Copy: `Every device is polled on a configurable interval via NAPALM.
Interface status, ARP tables, DHCP leases, VLAN membership, Docker
containers, and SNMP health metrics — all in one place.`
**Card 3 — Configure & Enforce**
- Icon: `WrenchScrewdriverIcon`
- Heading: `Detect drift. Fix it.`
- Copy: `Define desired state in netOrk. On every poll, device config is
compared against it. Drifted devices get a warning; a one-click fix
stream applies the correction and shows you live SSH output.`
---
### Section 4 — Driver Grid
**Purpose:** Show breadth of vendor support.
**Layout:** Centered heading + wrapping badge grid.
**Heading:** `Works with your hardware`
**Subheading:**
```
netOrk ships with custom NAPALM drivers for 11 device types, plus all
built-in NAPALM drivers. New drivers follow a documented registration
pattern.
```
**Badge list** (see `docs/PRODUCT.md` — driver table):
OpenWRT, OPNsense, Proxmox VE, Linux, HP ProCurve / Aruba, TP-Link Jetstream,
Netgear, Fritz!Box, Zyxel, OpenMediaVault, Sonos,
Cisco IOS, Arista EOS, Juniper JunOS
Each badge uses the `Driver / Integration Badge` component from DESIGN.md.
---
### Section 5 — Screenshot Walkthrough (alternating)
**Purpose:** Show the UI concretely. Three alternating image + text rows.
**Row 1 — Left text, right screenshot**
- Heading: `Device detail at a glance`
- Copy: `Hostname, IP, vendor, OS version, last poll time, and active
warnings on one card. Tabbed detail view for interfaces, LLDP neighbors,
ARP table, VLAN membership, packages, services, and scheduled jobs.`
- Screenshot: DeviceDetailPage
**Row 2 — Right text, left screenshot**
- Heading: `Intent-based VLAN and SSID management`
- 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)
---
### Section 5b — NIS2
**Purpose:** Hook for organizations evaluating netOrk in a NIS2 context.
**Layout:** Left column — label + Art. 21 mapping list. Right column — mock compliance overview UI.
**Label (eyebrow):** `NIS2 · Art. 21` (sky-500, uppercase, tracking-widest)
**Heading:** `Evidence, not paperwork.`
**Copy:**
```
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.
```
**Art. 21 mapping (4 rows, icon = monospace article ref in sky-500):**
- Art. 21 (2e) → Patch & vulnerability management — Per-device update status, Wazuh CVE counts by severity
- Art. 21 (2h) → Asset management & access control — Full device inventory, RBAC with four roles, complete audit log
- Art. 21 (2a) → Risk analysis baseline — Config drift detection, SNMP health metrics, security agent coverage
- Art. 21 (2b) → Incident detection — Wazuh alert history, CrowdSec decisions, Graylog syslog per device
**Mock UI (right column):** `MockCompliance` — per-site checklist with ✓/⚠ rows, each showing label + detail stat. Label: `netork.local / compliance / HQ`.
---
### Section 6 — Plugin System (brief)
**Purpose:** Signal extensibility without going deep.
**Layout:** Dark card, left-aligned.
**Heading:** `Built to extend`
**Copy:**
```
Integrations (Wazuh, Graylog, CrowdSec, apt-cacher-ng) 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.
```
**CTA:** `Plugin system docs →` → `/plugins`
---
### Section 7 — Deployment (quick)
**Purpose:** Answer "how do I run this?" without going into detail.
**Layout:** Code block + short description.
**Heading:** `Self-hosted. One command.`
**Copy:**
```
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.
```
**Code block:**
```bash
# Clone + configure
git clone https://gitea.example.com/netork/netork.git
cp .env.example .env
# edit .env (DB URL, Redis password, secret key)
# Deploy
bash scripts/deploy.sh 192.168.1.10
```
---
### Section 8 — CTA Footer
**Layout:** Centered, full-width dark section.
**Heading:** `Start managing your network.`
**CTA:** `Read the docs →` → `/docs/getting-started`
---
## `/features` — Full Feature List
**Purpose:** Comprehensive reference for people who want to evaluate in depth.
**Layout:** Vertical list of expandable sections (or just long-scroll with
sticky section nav). One section per capability area.
**Sections** (map directly to feature list in `docs/PRODUCT.md`):
1. Device Management
2. Discovery
3. VM Provisioning
4. Supported Drivers (full table)
5. Networking & Inventory
6. Configuration Management & Drift
7. Configuration Automation (Ansible)
8. Scheduled Operations
9. Satellite Deployments
10. Monitoring & Health
11. Dashboards
12. Security Integrations
13. DNS Management
14. RADIUS Management
15. Access Control (RBAC)
16. NetBox Sync
17. Compliance & Audit (NIS2)
18. Developer Experience
Each section: `text-xl font-semibold text-slate-200` heading +
feature items as a clean list with `text-slate-400` body.
---
## `/drivers` — Supported Devices
**Purpose:** One-page reference for "does netOrk support my device?"
**Layout:** Full table + short description per driver.
**Table columns:** Driver name | Device type | Capabilities | Status
**Capabilities** — checkmarks or tags for:
- `get_facts` `get_interfaces` `get_lldp` `get_vlans` `get_ssids`
`get_health_metrics` `get_docker` `scheduled_reboot` `config_push`
**Status:** `stable` / `beta` / `community` as a badge.
---
## `/docs/getting-started` — Installation
**Purpose:** Get someone from zero to a running instance.
**Sections:**
1. **Prerequisites**
- Docker + Docker Compose
- A PostgreSQL instance (or use the bundled profile)
- Redis
- A Linux host reachable by SSH from the server
2. **Quick start**
```bash
git clone ...
cp .env.example .env
# Edit .env
bash scripts/deploy.sh <server-ip>
```
3. **First run**
- Navigate to `http://<server-ip>`
- Complete the setup wizard (creates admin user)
- Add your first device
4. **Adding a device**
- Fill in hostname/IP, driver, and credentials
- Click Poll to verify connectivity
- Set a poll interval for continuous monitoring
5. **Next steps**
- Configure NetBox sync
- Set up Wazuh integration
- Enable scheduled reboots for OpenWRT APs
---
## `/roadmap` — Roadmap
**Purpose:** Show what's being built and what's under consideration. Signal NIS2 investment clearly.
**Layout:** Page header + two vertical groups ("Planned" / "Under consideration"), each a list of items.
**NIS2 badge:** `NIS2` monospace tag (sky-500/10 bg, sky-400 text, sky-500/20 border) inline next to item title.
**Intro copy:**
```
What's being built and what's being evaluated. Items tagged NIS2 directly
address NIS2 Art. 21 technical baseline requirements.
```
**Planned items (NIS2-tagged):**
- CVE tracking per device — NVD / OSV cross-reference
- Compliance dashboard — per-site Art. 21 checklist view
**Planned items (general):**
- Webhook engine — outbound events with HMAC signing
- Live job log streaming — WebSocket for all long-running tasks
- NetBox sync — manual trigger + status view
**Under consideration (NIS2-tagged):**
- Incident workflow — structured record + NIS2 Art. 23 Fristen-Tracker
**Under consideration (general):**
- mDNS scanner — media device discovery
- Prometheus + Grafana — metrics and dashboards
- Kubernetes Helm chart
---
## `/docs/architecture` — Technical Overview
**Purpose:** Give engineers the mental model before they look at code.
**Content:** Essentially the one-paragraph summary from `docs/PRODUCT.md`
expanded into a readable overview with the architecture diagram (ASCII or SVG).
**Sections:**
1. Overview (request → FastAPI → DB / Celery worker)
2. Driver system (NAPALM + custom drivers + registry)
3. Task queues (which queue does what)
4. Plugin system (register → hook bus → router mount)
5. Data model (UUID PKs, JSONB snapshots, intent-vs-state)
---
## `/plugins` — Plugin System
**Purpose:** Explain extensibility to potential contributors.
**Sections:**
1. What is a plugin? (metadata, router, tasks, hooks)
2. Built-in plugins (Wazuh, Graylog, CrowdSec, apt-cacher)
3. Writing a plugin (step-by-step with code snippets)
4. Hook bus (fire / call / transform)
5. Plugin registry and enable/disable
---
## `/for/*` — Persona Pages
**Purpose:** Answer "is this for me?" from the perspective of a specific
buyer/user, instead of one generic homepage pitch. Reachable via the "Für
wen" / "Who it's for" nav dropdown.
**Shared layout:** hero (icon + heading + sub) → "Your day today" pain-point
cards (persona-specific, concrete workflow friction) → feature-callout cards
(only shipped capabilities, cited from `docs/PRODUCT.md`) → CTA block linking
to `/docs/getting-started`. Same card/section classes as `/plugins`.
- **`/for/it-department`** — core admin/engineer audience. Pain points:
per-vendor admin UIs, no single inventory view, undocumented config
changes, manual SSH just to check state. Features: Device Management,
config drift + one-click fix, Git-backed config history, Ansible
automation, VM Provisioning, Dashboards. Plus a short supported-drivers
strip linking to `/drivers`.
- **`/for/it-support`** — day-to-day operators, less config depth. Pain
points: "is it up right now?", repeated manual reboots, no change record,
full admin access for one ticket. Features: warning system + dashboard
widget, one-click Ack, Wake-on-LAN, scheduled reboots/updates, filterable
audit log + export, roles scoped below engineer level.
- **`/for/msp`** — managed service providers, strongest standalone buying
case. Pain points: unreachable client sites, no cross-client view, proving
what was done, client data in someone else's cloud. Features: Satellite
Deployments, automatic routing around unreachable sites (with the honest
caveat that SNMP metrics + WebSSH still need direct reach), audit trail as
client-facing evidence, per-technician custom roles (**not** phrased as
per-site RBAC — netOrk's roles are global permission sets, not
site-scoped), self-hosted/no per-seat SaaS.
The existing `/nis2` page (security/compliance persona) is linked from the
same dropdown rather than duplicated.
---
## Global Layout
### Navigation (all pages)
```
[ netOrk ] Features Drivers Docs ▾ Für wen ▾ Plugins Roadmap [ Get started ]
```
`Docs ▾`: Getting Started / Architecture / NIS2 Compliance / Glossary
`Für wen ▾`: IT Department / IT Support / MSP / NIS2 Compliance (reuses the Docs dropdown's NIS2 link/label)
### Footer
```
netOrk — self-hosted network orchestration
Links: Resources: Legal:
Features Getting Started MIT License
Drivers Architecture Privacy (none collected)
Plugins Changelog
Roadmap NIS2
Glossary
For IT Departments
For IT Support
For MSPs
```
Footer background: `bg-slate-900 border-t border-slate-800`
Footer text: `text-sm text-slate-500`
| Home | `/` | 400 |
| Pricing | `/pricing` | 300 |
| NIS2 | `/nis2` | 550 |
| Plugins | `/plugins` | 450 |
| Persona ×3 | `/for/it-department`, `/for/it-support`, `/for/msp` | 320 |
| Getting started | `/docs/getting-started` | 80 |
| Glossary, Impressum, Datenschutz | `/glossary`, `/impressum`, `/datenschutz` | — |
Old routes redirect (in `App.tsx` and as 301 in `nginx.conf`): `/features` →
`/#included`, `/drivers` → `/#hardware`, `/roadmap` and `/docs/architecture` → `/`.
## Home
1. **Hero** — "Control instead of drift." The drift comparison of an access
point (`drift`, `drift-narrow` on phones) with a caption saying what is in it.
2. **How it works** (`#how`) — Define → Detect → Fix, three steps.
3. **Hardware** (`#hardware`) — two lines of names: "desired state and fixes"
and "inventory and monitoring"; a note on the untested NAPALM drivers.
4. **Vulnerabilities, with a licence** — the triage queue (`vulnerabilities`),
link to Pricing.
5. **Also in the box** (`#included`) — eight terms, one short line each; a NIS2
row with a link.
6. **Closing band** — "netOrk is free. The licence adds the data."
## Pricing
The free core as one row, then Starter / Pro / Enterprise, each "on request"
with a "Buy a licence" button to the licence portal (`src/data/plans.ts`). Plan
differences come from the licence server's plan defaults; the KB request limit
stays off the page until it has a unit. Four questions below, and the note that
there is no public installer yet.
## Persona pages
One template (`Persona.tsx`): header, three problems, five ways netOrk helps
(desired state and drift first), the shared closing band. The IT-department
page shows the drift screenshot. The service-provider page says where it
stops today.
## NIS2
Article by article (eight rows plus one "out of scope"), a dot and a word for
coverage, what netOrk records along the way, the audit log screenshot. States
plainly that netOrk does not make anyone compliant.
## Plugins, Glossary, Getting started, Impressum, Datenschutz
Plugins: the five included ones with the hosts they talk to, how to write one,
one code example. Glossary: every term from `src/glossary/terms.ts` with a
category index. Getting started: "no public installer yet", write to us.
Impressum and Datenschutz: **placeholders** — the final text must replace the
yellow box before netork.io goes live.
## Navigation and footer
Nav: wordmark · Who it's for ▾ · NIS2 · Plugins · Pricing · DE/EN · "Buy a
licence". Below `lg` a menu button. Footer: product links, persona links,
contact, "no cookies, no tracking, no requests to anyone else", © line with
Impressum and Datenschutz.
+97 -15
View File
@@ -1,5 +1,22 @@
# netOrk — Product Description
## Positioning (since 2026-09)
- **Message:** control instead of drift. Define once how the network should be
set up; netOrk notices every change on access points, switches and firewalls,
shows what deviates and puts it back. Automatic fixing exists for access point
profiles; firewall profiles are compared and applied on demand; switch VLANs
are provisioned centrally; every configuration change is versioned in Git and
flagged when netOrk did not make it.
- **Primary audience:** IT departments in small and mid-sized companies.
- **Licence model:** netOrk itself is free. A licence (Starter / Pro /
Enterprise, price on request, sold through the licence portal) adds
vulnerability data from the netOrk Knowledge Base and image updates. One key
per netOrk instance; no limits on devices, sites or users. Plan differences:
vulnerability history 90 days / 1 year / 10 years, match evidence and CWE
details from Pro, the edge update channel for Enterprise. Source:
license-server plan defaults, mirrored in `src/data/plans.ts`.
## One-liner
**netOrk is a self-hosted network orchestration platform that discovers,
@@ -95,12 +112,22 @@ hardware and want operational visibility beyond what consumer dashboards offer.
- Vendor/model/OS auto-populated from NAPALM `get_facts()`
- Site assignment with FK to structured Site records
- AP Profile assignment for grouped OpenWRT config
- Web SSH terminal: sessions log in with each user's own SSH key, never the
device's shared account; opened and refused sessions are recorded. Sessions
are movable, dockable windows that survive navigating away
- A device can hold several roles at once (e.g. storage + hypervisor + Linux)
- One device per address per site; duplicates are refused (VMs exempt)
- Business criticality per device and site, used in vulnerability ranking
### Discovery
- ICMP ping sweep, SNMP scan, HTTP/HTTPS probing
- Device fingerprinting: vendor + platform confidence scoring
- FQDN resolution (reverse DNS)
- Manual adoption from scan results (no auto-create to avoid inventory noise)
- Discovery jobs in a sortable, filterable table, grouped per site
- LAN Scan: ping sweep from netOrk, each site satellite and every firewall;
live results with MAC and manufacturer; a finished scan becomes a discovery
job in one step
### VM Provisioning
- Cloud-Init based VM creation directly from a hypervisor's VMs tab — no
@@ -124,23 +151,32 @@ Custom NAPALM drivers for all of the following:
| Driver | Device type |
|---|---|
| `openwrt` | OpenWRT access points |
| `opnsense` | OPNsense firewalls |
| `proxmox` | Proxmox VE hypervisors |
| `fritzbox` | AVM Fritz!Box routers (read-only) |
| `hpe_officeconnect` | HPE OfficeConnect 1820 / 1920S switches |
| `linux` | Generic Linux servers |
| `procurve` | HP ProCurve / Aruba switches |
| `tplink_jetstream` | TP-Link Jetstream managed switches |
| `netgear` | Netgear switches |
| `fritzbox` | AVM Fritz!Box routers |
| `zyxel` | Zyxel switches |
| `netgear_plus` | Netgear Plus switches (web UI) |
| `netgear_smart` | Netgear Smart Managed Pro switches |
| `openmediavault` | OpenMediaVault NAS |
| `openwrt` | OpenWrt routers and access points |
| `opnsense` | OPNsense firewalls |
| `procurve` | HPE ProCurve / Aruba switches |
| `proxmox` | Proxmox VE hypervisors |
| `qnap_qts` | QNAP NAS on QTS |
| `sonos` | Sonos speakers |
| `tplink_jetstream` | TP-Link JetStream managed switches |
| `yealink` | Yealink IP phones |
| `zyxel` | Zyxel VMG routers (not switches) |
Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper JunOS.
The built-in NAPALM drivers (Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper JunOS)
are installed but not tested with netOrk and get none of its driver-specific
features. Capability matrix (audited against v0.28.0): see `src/pages/Drivers.tsx`.
Reboot from netOrk actually restarts only OpenWrt and Proxmox.
### Networking & Inventory
- Interface browser with IPv4/IPv6 addresses, MAC, speed, MTU
- LLDP neighbor discovery and topology graph
- LLDP neighbor discovery and topology graph, plus links derived from switch
MAC tables (drawn dashed)
- Radio problems between the access points of a site are reported
- ARP table and DHCP lease browser per device
- Subnet browser with interface-to-subnet assignments
- VLAN list grouped by site; per-VLAN device membership view
@@ -170,8 +206,10 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
### Configuration Automation (Ansible)
- Reusable Ansible roles and playbooks stored and edited directly in
netOrk — no separate git checkout
- 11 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy,
portainer, watchtower, uptime-kuma, vaultwarden, wireguard, fail2ban
- 16 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy,
portainer, watchtower, uptime-kuma, vaultwarden, stalwart, bulwark, searxng,
postiz, listmonk, wireguard, fail2ban
- Roles state their resource needs; undersized hosts are refused with a reason
- Automatic dependency resolution — assigning `docker` pulls in `base`
automatically, no manual role ordering
- Built-in roles can't be deleted but are fully editable; customizations
@@ -215,7 +253,7 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
- One-click Ack on any warning — clears it immediately and writes an audit log
entry; for config-change warnings the current state is accepted as the new
baseline
- Docker container and image status (Proxmox/Linux)
- Docker container and image status (Linux, OpenMediaVault, QNAP)
- Service status and start/stop/restart (systemd)
- VM/container list with OS device cross-linking (Proxmox)
- Per-device availability windows — suppress OFFLINE status and poll-failure
@@ -225,21 +263,65 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
- OPNsense: TLS certificate monitoring for the Trust store, with
expiring-soon / expired warnings
- OPNsense: Dynamic DNS service-down warning (os-ddclient)
- Service checks about once a minute (DNS, NTP, VPN tunnels, core daemons,
gateways), derived automatically; three failures before an alert; can run
from satellites, including a DHCP check
- Site reachability: polling pauses behind a dead tunnel, one warning names
it, everything is re-polled when it returns
### Dashboards
- Configurable, shareable dashboards — build your own from a widget picker
instead of a fixed layout
- WYSIWYG grid-layout editor: drag, resize, and arrange widgets on a canvas
- 13 widget types: stats, device warnings, recently updated devices, network
- 18 widget types: stats, device warnings, recently updated devices, network
topology, EOL status, config drift summary, Wazuh security alerts, audit log
activity, discovery jobs status, upcoming scheduled actions, DNS zones
overview, site overview, config snapshot history
overview, site overview, config snapshot history, managed services,
certificate expiry, outdated Docker images, firewall profile deployment
status, service checks
- Multi-instance widgets with independent per-widget settings
- Share a dashboard with specific users; recipients can subscribe to the
owner's live version or clone it into their own editable copy
- Favorite dashboards for quick access from the main menu; set any dashboard
as your home view
### Notifications
- Signal messages for everything netOrk watches; each person registers their
own number, administrators pair netOrk once via QR code
- One message per site outage, daily summary for recurring items, hourly
bundling, quiet hours per number, mute per kind, full history with reasons
### DHCP
- DHCP reservations: import from the firewall, validated, diff, then apply
(adds and updates only)
- DHCP subnets (Kea on OPNsense) with options and search domains; settings
that break a network are refused
### Managed Services
- Every container-based service across devices with endpoints, TLS
certificates and access rules
- Compose editor with masked secrets and automatic backup snapshot; redeploy
is a separate confirmed step
- Zoraxy vhosts editable and written back; PostgreSQL databases listed
### Security Assessment
- Security tab per device: TLS/SSH grades A–F, installed software and
container images matched against known vulnerabilities, hardening benchmarks
- Ratings adjusted to the device (local access, trusted network, not running,
not booted kernel; raised when exploited in the wild)
- Kernel reboot recommendation with the vulnerabilities it would clear
- Exposure from firewall rules; internet-visible ports and abuse reports for
own public addresses; on-demand hardening audit and web scan
- Vulnerability data from the netOrk Knowledge Base (licence required)
### Vulnerability Management
- Triage queue across all devices, one row per vulnerability, ordered by
remediation deadline, exploitation, severity, likelihood, criticality, spread
- Decisions (not applicable / accept until / defer until / fixed) with a
mandatory reason; accept and not-applicable need an elevated permission
- Deferred and accepted items return by themselves; ignored ones go overdue
- Daily reassessment verifies fixes and reopens regressions
### Security Integrations (plugins)
- **Wazuh** — agent enrollment tracking, vulnerability counts (by severity),
recent alert history, CIS benchmark scores, one-click agent install fix stream
+6 -5
View File
@@ -1,14 +1,15 @@
<!doctype html>
<html lang="en" class="scroll-smooth">
<html lang="de" class="motion-safe:scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="netOrk — self-hosted network orchestration for heterogeneous infrastructure. Discover, monitor, and manage routers, switches, firewalls, and servers from a single UI." />
<title>netOrk — Network Orchestration Platform</title>
<meta name="theme-color" content="#FAFAF9" />
<meta name="description" content="netOrk: define once how your network should be set up. It notices every change on access points, switches and firewalls, shows what deviates and puts it back. Self-hosted, across vendors." />
<title>netOrk — Control instead of drift</title>
<link rel="icon" href="/logo.png" />
<meta property="og:image" content="/logo.png" />
<meta property="og:title" content="netOrk — Network Orchestration Platform" />
<meta property="og:description" content="Self-hosted network orchestration for heterogeneous infrastructure." />
<meta property="og:title" content="netOrk — Control instead of drift" />
<meta property="og:description" content="Self-hosted network automation for IT teams: one desired state, every deviation found and fixed." />
</head>
<body>
<div id="root"></div>
+10
View File
@@ -9,6 +9,16 @@ server {
gzip_min_length 1024;
gzip_vary on;
# Relative Location headers: TLS ends at Zoraxy, so an absolute redirect
# built here would point at http://.
absolute_redirect off;
# Pages folded into the homepage (see App.tsx for the same list client-side).
location = /features { return 301 /#included; }
location = /drivers { return 301 /#hardware; }
location = /roadmap { return 301 /; }
location = /docs/architecture { return 301 /; }
# SPA fallback — all routes resolve to index.html
location / {
try_files $uri $uri/ /index.html;
+10 -39
View File
@@ -8,6 +8,7 @@
"name": "netork-website",
"version": "1.0.0",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@heroicons/react": "^2.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -710,6 +711,15 @@
"node": ">=12"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
@@ -915,9 +925,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -932,9 +939,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -949,9 +953,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -966,9 +967,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -983,9 +981,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1000,9 +995,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1017,9 +1009,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1034,9 +1023,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1051,9 +1037,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1068,9 +1051,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1085,9 +1065,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1102,9 +1079,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1119,9 +1093,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@heroicons/react": "^2.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

+156
View File
@@ -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())
+29
View File
@@ -0,0 +1,29 @@
# Demo instance for screenshots
The website shows real netOrk screens, taken from a local copy of a production
database with every hostname, domain, address, MAC and name replaced.
```
pg_dump -Fc ... > netork.dump # on the production host, by hand
scripts/demo/up.sh restore netork.dump # fresh local DB + anonymize.py
scripts/demo/up.sh start # API :8000, UI http://127.0.0.1:5173
scripts/screenshots/capture.py --list-devices
scripts/screenshots/capture.py --var ap=<id> --var switch=<id> --var server=<id>
```
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`.
- `anonymize.py` ends with a leak report. Read it before taking screenshots,
and look at every image before committing it.
- The dump file itself holds production data: keep it out of the repo and
delete it when done.
+470
View File
@@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""Turn a restored copy of a production netOrk database into demo data.
anonymize.py [--dsn postgresql://...] [--map demo-map.json] [--dry-run]
Run it against the LOCAL copy only; it refuses anything that is not
localhost. It works on every text-like column of every table instead of a
hand-kept list, so a table added in a later release is covered too:
* domains every configured domain (e.g. corp.example.com, acme.io) becomes
`example.demo`, subdomains kept: gw.home.corp.example.com ->
gw.home.example.demo
* IPv4 private addresses move to another /16 per /16, host part kept,
so subnets and VLAN plans still line up; public addresses are
mapped one by one into the documentation ranges
* IPv6 global prefixes go to 2001:db8::/32, interface IDs are hashed
* MAC the vendor prefix (OUI) is kept, so manufacturer lookups still
work; the device part is hashed
* e-mail local part hashed, domain example.demo
* names hostnames, site names, VLAN names, user names ... from the map
* secrets stored credentials, keys, tokens, TOTP and secret settings are
emptied; one admin `netork` with a known password is left
Every mapping is deterministic, so the same address always turns into the
same fake one, across tables, JSON documents and log lines alike. At the end
a leak report lists anything that still looks like the original.
"""
import argparse
import asyncio
import hashlib
import ipaddress
import json
import os
import re
import secrets
import sys
from pathlib import Path
import asyncpg
DEFAULT_DSN = "postgresql://netork:demo@127.0.0.1:55432/netork"
DEFAULT_MAP = Path.home() / ".config" / "netork-screenshots" / "demo-map.json"
DEMO_DOMAIN = "example.demo"
# Public reference data: large, and nothing in it is about the instance.
SKIP_TABLES = {
"alembic_version", "cwe_entries", "epss_scores", "nvd_cpe_matches",
"nvd_cpe_products", "nvd_cve_requirements", "nvd_cves", "osv_affected",
"osv_vulns", "oui_vendors", "service_templates",
}
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.
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"}
IPV4 = re.compile(r"(?<![\d.])((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})(?!\d|\.\d)")
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.])")
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,}")
def h(value: str, n: int) -> str:
return hashlib.sha256(value.encode()).hexdigest()[:n]
class Mapper:
def __init__(self, cfg: dict):
# {"home.corp.example.com": "hq.example.demo", "corp.example.com": "example.demo"}
self.domains: dict[str, str] = cfg.get("domains", {})
self.prefix16 = dict(cfg.get("ipv4_prefix16", {}))
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_used: set[str] = set()
# 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.
self.known_public: set[str] = set(cfg.get("public_ips", []))
self.unmapped_public: dict[str, int] = {}
self.public_pool = iter(
[f"203.0.113.{n}" for n in range(10, 250)] + [f"198.51.100.{n}" for n in range(10, 250)])
names = {**cfg.get("hostnames", {}), **cfg.get("terms", {})}
self.names = names
self.names_re = None
if names:
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 '_'.
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
if self.domains:
alt = "|".join(re.escape(d) for d in sorted(self.domains, key=len, reverse=True))
# Lazy prefix, so the longest configured domain wins.
self.domain_re = re.compile(rf"(?<![\w-])((?:[\w-]+\.)*?)({alt})(?![\w-])", re.I)
# -- single values -------------------------------------------------------
def ipv4(self, ip: str) -> str:
a = ipaddress.IPv4Address(ip)
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:
return ip
if any(a in net for net in PRIVATE_NETS):
p = ".".join(ip.split(".")[:2])
if p not in self.prefix16:
self.prefix16[p] = next(self.pool16)
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:
self.unmapped_public[ip] = self.unmapped_public.get(ip, 0) + 1
return ip
if ip not in self.public:
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]
def mac(self, m: str) -> str:
sep = m[2]
hexs = m.replace(sep, "")
new = hexs[:6] + h(hexs.lower(), 6)
new = new.upper() if hexs.isupper() else new.lower()
return sep.join(new[i:i + 2] for i in range(0, 12, 2))
def mac_dot(self, m: str) -> str:
hexs = m.replace(".", "")
new = hexs[:6] + h(hexs.lower(), 6)
return ".".join(new[i:i + 4] for i in range(0, 12, 4))
def ipv6(self, s: str) -> str:
# "Data::" or "12:30:45" are no addresses; demand three real groups.
if sum(1 for g in s.split(":") if g) < 3:
return s
try:
a = ipaddress.IPv6Address(s)
except ValueError:
return s # a time like 12:30:45 or similar, not an address
if a.is_loopback or a.is_unspecified or a.is_multicast:
return s
iid = h(a.packed[8:].hex(), 16)
if a.is_link_local:
prefix = "fe80:0000:0000:0000"
elif a.is_private: # ULA fd00::/8, keep it ULA
prefix = "fd00:" + h(a.packed[:8].hex(), 12)
prefix = prefix[:4] + ":" + prefix[5:9] + ":" + prefix[9:13] + ":" + prefix[13:17].ljust(4, "0")
else:
p = h(a.packed[:8].hex(), 8)
prefix = f"2001:0db8:{p[:4]}:{p[4:]}"
full = prefix + ":" + ":".join(iid[i:i + 4] for i in range(0, 16, 4))
return str(ipaddress.IPv6Address(full))
def email(self, m: re.Match) -> str:
e = m.group(0)
if e.endswith("@" + DEMO_DOMAIN):
return e
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 -------------------------------------------------------
@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(self._secret, s)
s = REVERSE.sub(self.reverse, s)
s = EMAIL.sub(self.email, s)
if self.domain_re:
s = self.domain_re.sub(lambda m: m.group(1) + self.domains[m.group(2).lower()], s)
s = MAC.sub(lambda m: self.mac(m.group(1)), s)
s = MAC_DOT.sub(lambda m: self.mac_dot(m.group(1)), s)
s = IPV6.sub(lambda m: self.ipv6(m.group(1)), s)
s = IPV4.sub(lambda m: self.ipv4(m.group(1)), s)
if self.names_re:
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
# Cheap server-side prefilter: only rows that could contain something to map.
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}[:-]",
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"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))
return "|".join(parts)
# 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|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"}
# 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.
SECRET_TABLES = ["user_ssh_keys", "user_backup_codes", "notification_deliveries",
"notification_mutes", "notification_channels", "trusted_networks"]
async def columns(con) -> list[tuple[str, str, str]]:
rows = await con.fetch(
"SELECT table_name, column_name, data_type FROM information_schema.columns "
"WHERE table_schema = 'public' ORDER BY table_name, ordinal_position")
return [(r[0], r[1], r[2]) for r in rows
if r[0] not in SKIP_TABLES and r[2] in TEXT_TYPES]
ADDRESS_COLUMN = re.compile(r"(^|_)(ip|ips|ip_address|address|addr|host|target|source|wan|gateway|peer|value)(_|$)")
QUAD = r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
# A dotted quad reads as an address when it is a whole JSON string value (not
# under a version-like key) or follows a word that introduces an address.
AS_JSON_VALUE = re.compile(rf'(?:"([^"]*)"\s*:\s*)?"({QUAD})(?:/\d{{1,2}})?"')
AS_PROSE = re.compile(
rf"(?i)\b(?:from|to|ip|ipv4|addr|address|host|src|dst|source|peer|wan|gateway|gw|via|at|by|nameserver|server)\W{{1,3}}({QUAD})")
VERSIONISH = re.compile(r"(?i)version|ver$|release|build|firmware|kernel|rev")
def addresses_in(value: str, whole_column: bool) -> set[str]:
found = set()
if whole_column:
found.update(IPV4.findall(value))
for key, ip in AS_JSON_VALUE.findall(value):
if not (key and VERSIONISH.search(key)):
found.add(ip)
found.update(AS_PROSE.findall(value))
return found
async def collect_public(con, mapper: Mapper) -> None:
"""Learn which public IPv4 addresses really are addresses."""
for t, c, dt in await columns(con):
whole = dt in ("inet", "cidr") or bool(ADDRESS_COLUMN.search(c))
rows = await con.fetch(
f'SELECT DISTINCT "{c}"::text AS v FROM "{t}" WHERE "{c}"::text ~ $1', QUAD)
for r in rows:
for ip in addresses_in(r["v"], whole):
try:
a = ipaddress.IPv4Address(ip)
except ValueError:
continue
if a.is_global and ip not in KEEP_PUBLIC:
mapper.known_public.add(ip)
async def scrub_secrets(con, dry: bool) -> None:
rows = await con.fetch(
"SELECT c.table_name, c.column_name, c.is_nullable, c.data_type "
"FROM information_schema.columns c JOIN information_schema.tables t "
"ON t.table_name = c.table_name AND t.table_schema = c.table_schema "
"WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE'")
for t, c, nullable, dt in rows:
if t in SKIP_TABLES or c in SECRET_KEEP or not SECRET_COLUMN.search(c):
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"
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")
print(f" settings: {n} secret values emptied")
if not dry:
await con.execute("UPDATE settings SET value = '' WHERE is_secret")
for t in SECRET_TABLES:
if await con.fetchval("SELECT to_regclass($1) IS NOT NULL", f"public.{t}"):
n = await con.fetchval(f'SELECT count(*) FROM "{t}"')
print(f" {t}: {n} rows deleted")
if not dry:
await con.execute(f'DELETE FROM "{t}"')
async def rewrite(con, mapper: Mapper, cfg: dict, dry: bool) -> None:
pat = prefilter(cfg)
by_table: dict[str, list[tuple[str, str]]] = {}
for t, c, dt in await columns(con):
by_table.setdefault(t, []).append((c, dt))
for table, cols in by_table.items():
for col, dt in cols:
q = f'SELECT ctid, "{col}"::text AS v FROM "{table}" WHERE "{col}"::text ~ $1'
rows = await con.fetch(q, pat)
updates = []
for r in rows:
new = mapper.text(r["v"])
if new != r["v"]:
updates.append((new, r["ctid"]))
if not updates:
continue
print(f" {table}.{col}: {len(updates)} rows")
if dry:
continue
cast = {"jsonb": "::jsonb", "json": "::json", "inet": "::inet", "cidr": "::cidr",
"macaddr": "::macaddr"}.get(dt, "")
if dt == "ARRAY":
udt = await con.fetchval(
"SELECT udt_name FROM information_schema.columns "
"WHERE table_name = $1 AND column_name = $2", table, col)
cast = f"::{udt.lstrip('_')}[]"
await con.executemany(
f'UPDATE "{table}" SET "{col}" = $1{cast} WHERE ctid = $2', updates)
async def reset_users(con, cfg: dict, dry: bool) -> None:
sys.path.insert(0, str(Path(cfg["netork_src"]).expanduser()))
from netork.core.security import hash_password # noqa: E402
admin = cfg.get("admin_from", "chris")
password = cfg.get("admin_password", "netork-demo")
users = await con.fetch("SELECT id, username FROM users ORDER BY username")
print(f" users: {[u['username'] for u in users]}")
if dry:
return
n = 0
for u in users:
if u["username"] == admin:
await con.execute(
"UPDATE users SET username = 'netork', email = $2, hashed_password = $3, "
"totp_secret = NULL, totp_enabled = false, token_version = token_version + 1 "
"WHERE id = $1", u["id"], f"netork@{DEMO_DOMAIN}", hash_password(password))
else:
n += 1
await con.execute(
"UPDATE users SET username = $2, email = $3, hashed_password = $4, "
"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()))
# 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")
if role:
await con.execute("UPDATE users SET role_id = $1, is_superuser = true WHERE username = 'netork'", role)
print(f" admin '{admin}' is now 'netork' / '{password}'")
async def leak_report(con, cfg: dict, originals: list[str]) -> int:
# Names are matched as written (FAMILY is a VLAN, "family" a JSON key);
# 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
pat = "|".join(parts)
found = 0
for t, c, _ in await columns(con):
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~ $1', pat)
if n:
found += n
sample = await con.fetchval(
f'SELECT substring("{c}"::text from $2) FROM "{t}" WHERE "{c}"::text ~ $1 LIMIT 1',
pat, f"(.{{0,30}}(?:{pat}).{{0,30}})")
print(f" LEAK {t}.{c}: {n} rows, e.g. …{sample}…")
return found
async def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dsn", default=os.environ.get("DEMO_DSN", DEFAULT_DSN))
ap.add_argument("--map", type=Path, default=DEFAULT_MAP)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--report-only", action="store_true", help="only run the leak report")
args = ap.parse_args()
host = re.search(r"@([^:/]+)", args.dsn)
if not host or host.group(1) not in ("127.0.0.1", "localhost", "::1"):
sys.exit("Refusing: this only runs against a local copy.")
cfg = json.loads(args.map.read_text())
mapper = Mapper(cfg)
originals = [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
*cfg.get("leak_terms", [])]
con = await asyncpg.connect(args.dsn)
try:
if not args.report_only:
async with con.transaction():
print("secrets:")
await scrub_secrets(con, args.dry_run)
print("users:")
await reset_users(con, cfg, args.dry_run)
await collect_public(con, mapper)
print(f"public addresses seen as addresses: {len(mapper.known_public)}")
print("rewriting:")
await rewrite(con, mapper, cfg, args.dry_run)
print("ipv4 /16 mapping:", json.dumps(mapper.prefix16))
print("public addresses mapped:", len(mapper.public))
if mapper.unmapped_public:
top = sorted(mapper.unmapped_public.items(), key=lambda x: -x[1])[:40]
print("left as is (versions? add real ones to public_ips in the map):")
print(" " + ", ".join(f"{ip} ({n}x)" for ip, n in top))
print("leak report:")
n = await leak_report(con, cfg, originals)
if not args.report_only and mapper.unmapped_public:
print(f" review: {len(mapper.unmapped_public)} public-looking dotted quads left as is (listed above)")
print(" clean" if n == 0 else f" {n} rows still match")
finally:
await con.close()
if __name__ == "__main__":
asyncio.run(main())
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Local netOrk demo instance for website screenshots.
#
# up.sh restore <dump> fresh demo DB from a pg_dump -Fc file, then anonymize
# up.sh start API on :8000 and UI on :5173 (foreground, Ctrl-C stops)
# up.sh stop stop the demo database container
#
# Only the API and the UI run: no Celery worker, no beat, no Redis. Nothing
# polls, nothing reboots, nothing reaches a device. Stored credentials are
# emptied by anonymize.py and the encryption key is a fresh random one, so
# even a leftover value could not be decrypted.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
DEMO="${NETORK_DEMO_DIR:-$HOME/.cache/netork-demo}"
SRC="$DEMO/src"
VENV="${NETORK_VENV:-$HOME/dev/NetOrk/.venv}"
VERSION="${NETORK_DEMO_VERSION:-v0.28.0}"
NETORK_REPO="${NETORK_REPO:-$HOME/dev/NetOrk}"
DB=netork-demo-db
PORT=55432
ensure_src() {
if [ ! -d "$SRC/netork" ]; then
mkdir -p "$SRC"
git -C "$NETORK_REPO" archive "$VERSION" | tar -x -C "$SRC"
fi
}
ensure_db() {
if ! docker ps --format '{{.Names}}' | grep -qx "$DB"; then
docker start "$DB" 2>/dev/null || docker run -d --name "$DB" \
-p 127.0.0.1:$PORT:5432 -e POSTGRES_DB=netork -e POSTGRES_USER=netork \
-e POSTGRES_PASSWORD=demo -v netork-demo-pg:/var/lib/postgresql/data postgres:16-alpine
until docker exec "$DB" pg_isready -U netork -q; do sleep 1; done
fi
}
case "${1:-}" in
restore)
dump="${2:?usage: up.sh restore <dump file>}"
ensure_src; ensure_db
docker exec "$DB" psql -U netork -d postgres -q \
-c "DROP DATABASE IF EXISTS netork WITH (FORCE)" -c "CREATE DATABASE netork"
docker exec -i "$DB" pg_restore -U netork -d netork --no-owner --no-privileges < "$dump" \
|| echo "pg_restore reported errors (often only missing roles/extensions); checking ..."
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}')
echo "dump schema: $got $VERSION head: $want"
# 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"
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)
ensure_src; ensure_db
[ -d "$SRC/ui/node_modules" ] || (cd "$SRC/ui" && npm ci --no-audit --no-fund)
export DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork"
export ENVIRONMENT=development
export SECRET_KEY="$(openssl rand -hex 32)"
export CREDENTIAL_ENCRYPTION_KEY="$("$VENV/bin/python" -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
# Nothing listens on port 1: no task can be queued, so no worker could act.
export REDIS_URL=redis://127.0.0.1:1/0 CELERY_BROKER_URL=redis://127.0.0.1:1/0 CELERY_RESULT_BACKEND=redis://127.0.0.1:1/1
cd "$SRC"
"$VENV/bin/uvicorn" netork.api.main:app --host 127.0.0.1 --port 8000 &
api=$!
trap 'kill $api 2>/dev/null' EXIT
cd ui && npx vite --host 127.0.0.1 --port 5173 --strictPort
;;
stop)
docker stop "$DB"
;;
*)
sed -n '2,12p' "$0"; exit 1 ;;
esac
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Take real screenshots of a running netOrk instance for the website.
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> [--only name ...]
Against a real instance, log in by hand and cover what must not be seen:
NETORK_URL=https://... capture.py --login
NETORK_URL=https://... capture.py --mask --var ...
While capturing, every request to the API that is not a GET is aborted, so
taking screenshots cannot change anything on the instance.
"""
import argparse
import io
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from PIL import Image
from playwright.sync_api import Page, sync_playwright
from shots import SHOTS
BASE = os.environ.get("NETORK_URL", "http://127.0.0.1:5173").rstrip("/")
LOCAL = re.match(r"https?://(127\.0\.0\.1|localhost)[:/]", BASE + "/") is not None
# The demo instance's admin (see scripts/demo/anonymize.py).
USER = os.environ.get("NETORK_USER", "netork")
PASSWORD = os.environ.get("NETORK_PASSWORD", "netork-demo")
STATE = Path(os.environ.get(
"NETORK_STATE", Path.home() / ".cache" / "netork-screenshots" / "state.json"))
# 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"))
SITE = Path(__file__).resolve().parents[2]
OUT = SITE / "public" / "screenshots"
VIEWPORT = {"width": 1600, "height": 1000}
# Any IPv4 address that is not RFC 1918, loopback or link-local.
PUBLIC_IPV4 = re.compile(
r"\b(?!10\.)(?!127\.)(?!169\.254\.)(?!192\.168\.)(?!172\.(?:1[6-9]|2\d|3[01])\.)"
r"(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
def mask_terms() -> list[str]:
if not MASK_FILE.exists():
return []
return [t.strip() for t in MASK_FILE.read_text().splitlines()
if t.strip() and not t.startswith("#")]
def login() -> None:
STATE.parent.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
ctx = browser.new_context(ignore_https_errors=True, viewport=VIEWPORT)
page = ctx.new_page()
page.goto(f"{BASE}/login")
print("Log in in the browser window (10 minutes) ...", flush=True)
page.wait_for_function(
"() => localStorage.getItem('token') && !location.pathname.startsWith('/login')",
timeout=600_000)
ctx.storage_state(path=STATE)
STATE.chmod(0o600)
browser.close()
print(f"Session saved to {STATE}")
def token() -> str:
if LOCAL:
body = urllib.parse.urlencode({"username": USER, "password": PASSWORD}).encode()
try:
with urllib.request.urlopen(f"{BASE}/api/v1/auth/token", body) as res:
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:
sys.exit(f"Login as {USER} at {BASE} failed: {e} (is scripts/demo/up.sh start running?)")
state = json.loads(STATE.read_text())
for origin in state.get("origins", []):
for item in origin.get("localStorage", []):
if item["name"] == "token":
return item["value"]
sys.exit("No token in the saved session; run --login first.")
def list_devices() -> None:
with sync_playwright() as p:
req = p.request.new_context(
base_url=BASE, ignore_https_errors=True,
extra_http_headers={"Authorization": f"Bearer {token()}"})
res = req.get("/api/v1/devices/")
if not res.ok:
sys.exit(f"{res.status}: {res.text()[:200]} (session expired? run --login)")
for d in res.json():
print(f"{d.get('id')} {d.get('driver') or '-':18} "
f"{d.get('device_type') or '-':20} {d.get('hostname')}")
def settle(page: Page) -> None:
"""Wait until the page has finished loading its data."""
try:
page.wait_for_load_state("networkidle", timeout=15_000)
except Exception:
pass # pages that poll never go fully idle
try:
page.wait_for_function(
"() => !document.querySelector('.animate-spin, .animate-pulse')", timeout=15_000)
except Exception:
print(" still loading after 15 s, taking the shot anyway")
page.wait_for_timeout(800)
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:
OUT.mkdir(parents=True, exist_ok=True)
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"):
route.continue_()
else:
blocked.append(f"{route.request.method} {route.request.url}")
route.abort()
with sync_playwright() as p:
browser = p.chromium.launch()
ctx = browser.new_context(
storage_state=None if LOCAL else STATE, ignore_https_errors=True,
viewport=VIEWPORT, device_scale_factor=2, color_scheme="dark")
if tok:
ctx.add_init_script(f"localStorage.setItem('token', {json.dumps(tok)})")
ctx.route("**/api/**", guard)
page = ctx.new_page()
for shot in SHOTS:
if only and shot.name not in only:
continue
try:
path = shot.path.format(**variables)
except KeyError as e:
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.")
page.wait_for_selector(shot.wait_for, timeout=20_000)
settle(page)
# The release notes dialog after an upgrade; dismissing it only
# writes localStorage in this throwaway browser context.
got_it = page.get_by_role("button", name="Got it")
if got_it.is_visible():
got_it.click()
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]
if mask:
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.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")
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)):
print(f" {b}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--login", action="store_true", help="log in and save the session")
ap.add_argument("--list-devices", action="store_true", help="print device IDs")
ap.add_argument("--var", action="append", default=[], metavar="NAME=VALUE",
help="fill a {placeholder} in the shot paths")
ap.add_argument("--only", nargs="*", default=[], help="only these shot names")
ap.add_argument("--mask", action="store_true",
help="cover public IPs, e-mails and the mask-file terms (real instances)")
args = ap.parse_args()
if args.login:
login()
elif args.list_devices:
list_devices()
else:
capture(dict(v.split("=", 1) for v in args.var), set(args.only), args.mask)
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
"""The screenshots the website uses, as data.
Each shot is one page of the netOrk UI. `path` may contain `{placeholders}`
that are filled from `--var name=value` on the command line (device IDs
differ per instance, so they are never hard-coded here). Device detail
sections are addressed through the URL hash the UI itself writes
(`#security/assessment`, `#config`, ...), so no clicking is needed.
`mask` lists extra CSS selectors to cover on top of the automatic masks
(public IPv4 addresses, e-mail addresses, and the terms from the mask file).
"""
from dataclasses import dataclass, field
@dataclass
class Shot:
name: str
path: str
# Selector that must be visible before the shot is taken.
wait_for: str = "main"
mask: list[str] = field(default_factory=list)
full_page: bool = False
# 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
# capture.py aborts anything that would write.
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] = [
# 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", clip=(256, 40, 1344, 560), width=2400, clicks=[
"tbody td >> text=scheduler",
"button[title='Click to toggle include/exclude']",
]),
]
+21 -16
View File
@@ -1,36 +1,41 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Nav from './components/Nav'
import Footer from './components/Footer'
import ScrollManager from './components/ScrollManager'
import Home from './pages/Home'
import Features from './pages/Features'
import Drivers from './pages/Drivers'
import GettingStarted from './pages/GettingStarted'
import Roadmap from './pages/Roadmap'
import Pricing from './pages/Pricing'
import Persona from './pages/Persona'
import Nis2 from './pages/Nis2'
import Plugins from './pages/Plugins'
import Glossary from './pages/Glossary'
import ForItDepartment from './pages/ForItDepartment'
import ForItSupport from './pages/ForItSupport'
import ForMsp from './pages/ForMsp'
import GettingStarted from './pages/GettingStarted'
import Legal from './pages/Legal'
export default function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-slate-950 flex flex-col">
<ScrollManager />
<div className="flex min-h-screen flex-col">
<Nav />
<main className="flex-1">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/features" element={<Features />} />
<Route path="/drivers" element={<Drivers />} />
<Route path="/docs/getting-started" element={<GettingStarted />} />
<Route path="/roadmap" element={<Roadmap />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/for/it-department" element={<Persona id="itDepartment" />} />
<Route path="/for/it-support" element={<Persona id="itSupport" />} />
<Route path="/for/msp" element={<Persona id="msp" />} />
<Route path="/nis2" element={<Nis2 />} />
<Route path="/plugins" element={<Plugins />} />
<Route path="/glossary" element={<Glossary />} />
<Route path="/for/it-department" element={<ForItDepartment />} />
<Route path="/for/it-support" element={<ForItSupport />} />
<Route path="/for/msp" element={<ForMsp />} />
<Route path="/docs/getting-started" element={<GettingStarted />} />
<Route path="/impressum" element={<Legal page="imprint" />} />
<Route path="/datenschutz" element={<Legal page="privacy" />} />
{/* Pages that were folded into the homepage. nginx.conf answers the same paths with a 301. */}
<Route path="/features" element={<Navigate to="/#included" replace />} />
<Route path="/drivers" element={<Navigate to="/#hardware" replace />} />
<Route path="/roadmap" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<Footer />
+63 -39
View File
@@ -1,56 +1,80 @@
import { Link } from 'react-router-dom'
import { useLang } from '../context/LangContext'
import { CONTACT_EMAIL } from '../i18n/translations'
import { Container } from './ui'
import { Wordmark } from './Nav'
const linkCls = 'text-ink-muted transition-colors hover:text-ink'
export default function Footer() {
const { t } = useLang()
const f = t.footer
const columns = [
{
title: f.product,
links: [
{ to: '/pricing', label: t.nav.pricing },
{ to: '/nis2', label: t.nav.nis2 },
{ to: '/plugins', label: t.nav.plugins },
{ to: '/glossary', label: f.glossary },
{ to: '/docs/getting-started', label: f.gettingStarted },
],
},
{
title: f.forWhom,
links: [
{ to: '/for/it-department', label: t.nav.forItems.itDepartment },
{ to: '/for/it-support', label: t.nav.forItems.itSupport },
{ to: '/for/msp', label: t.nav.forItems.msp },
],
},
]
return (
<footer className="bg-slate-900 border-t border-slate-800 mt-auto">
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="flex flex-col md:flex-row gap-10 md:gap-16">
<div className="shrink-0">
<Link to="/" className="flex items-center gap-2.5 mb-3">
<img src="/logo.png" alt="" className="h-6 w-6" aria-hidden="true" />
<span className="font-semibold text-slate-100 tracking-tight">
net<span className="text-sky-400">Ork</span>
</span>
<footer className="mt-auto border-t border-line">
<Container className="py-14">
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1.5fr]">
<div>
<Link to="/">
<Wordmark />
</Link>
<p className="text-sm text-slate-500">{t.footer.tagline}</p>
<p className="mt-3 text-sm text-ink-muted">{f.tagline}</p>
</div>
<div className="grid grid-cols-3 gap-10 flex-1 text-sm">
<div>
<p className="font-medium text-slate-400 mb-3">{t.footer.links}</p>
<ul className="space-y-2">
<li><Link to="/features" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.features}</Link></li>
<li><Link to="/drivers" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.drivers}</Link></li>
<li><Link to="/plugins" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.plugins}</Link></li>
<li><Link to="/roadmap" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.roadmap}</Link></li>
</ul>
</div>
<div>
<p className="font-medium text-slate-400 mb-3">{t.footer.resources}</p>
<ul className="space-y-2">
<li><Link to="/docs/getting-started" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.gettingStarted}</Link></li>
<li><Link to="/docs/architecture" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.architecture}</Link></li>
<li><span className="text-slate-600">{t.footer.changelog}</span></li>
<li><Link to="/nis2" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.nis2}</Link></li>
<li><Link to="/glossary" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.glossary}</Link></li>
<li><Link to="/for/it-department" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forItDepartment}</Link></li>
<li><Link to="/for/it-support" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forItSupport}</Link></li>
<li><Link to="/for/msp" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forMsp}</Link></li>
</ul>
</div>
<div>
<p className="font-medium text-slate-400 mb-3">{t.footer.legal}</p>
<ul className="space-y-2">
<li><span className="text-slate-600">{t.footer.mit}</span></li>
<li><span className="text-slate-600">{t.footer.privacy}</span></li>
{columns.map((col) => (
<div key={col.title} className="text-sm">
<p className="font-medium text-ink">{col.title}</p>
<ul className="mt-3 space-y-2">
{col.links.map((l) => (
<li key={l.to}>
<Link to={l.to} className={linkCls}>
{l.label}
</Link>
</li>
))}
</ul>
</div>
))}
<div className="text-sm">
<p className="font-medium text-ink">{f.contact}</p>
<p className="mt-3">
<a href={`mailto:${CONTACT_EMAIL}`} className={linkCls}>
{CONTACT_EMAIL}
</a>
</p>
<p className="mt-3 text-ink-muted">{f.noTracking}</p>
</div>
</div>
<div className="mt-12 flex flex-wrap gap-x-6 gap-y-2 border-t border-line pt-6 text-sm text-ink-faint">
<span>© 2026 netOrk</span>
<Link to="/impressum" className="transition-colors hover:text-ink">
{f.imprint}
</Link>
<Link to="/datenschutz" className="transition-colors hover:text-ink">
{f.privacy}
</Link>
</div>
</Container>
</footer>
)
}
+6 -5
View File
@@ -45,16 +45,17 @@ export default function GlossaryMark({ id, children }: { id: string; children: R
ref={anchorRef}
to={`/glossary#${entry.id}`}
onMouseEnter={handleEnter}
className="group relative border-b border-dotted border-sky-500/60 hover:border-sky-400 hover:text-sky-300 transition-colors"
className="group relative underline decoration-ink-faint decoration-dotted underline-offset-4 transition-colors hover:text-accent hover:decoration-accent"
>
{children}
{/* display:none while hidden: an invisible box would still widen the page on phones */}
<span
className={`pointer-events-none absolute bottom-full z-20 mb-2 w-64 rounded-lg
border border-slate-700 bg-slate-900 p-3 text-left text-xs font-normal normal-case
text-slate-400 opacity-0 shadow-2xl transition-opacity group-hover:opacity-100
className={`pointer-events-none absolute bottom-full z-20 mb-2 hidden w-64 rounded-lg
bg-ink p-3 text-left text-xs font-normal normal-case leading-relaxed tracking-normal
text-paper/75 no-underline shadow-lg group-hover:block group-focus-visible:block
${PLACEMENT_CLASS[placement]}`}
>
<span className="mb-1 block font-semibold text-slate-200">{name}</span>
<span className="mb-1 block font-semibold text-paper">{name}</span>
{entry.definition[lang]}
</span>
</Link>
+92 -96
View File
@@ -1,30 +1,29 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
import { Bars3Icon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { useLang } from '../context/LangContext'
import type { Lang } from '../i18n/translations'
import { ButtonLink, Container } from './ui'
const dropdownItemCls =
'block px-4 py-2 text-sm text-slate-400 hover:text-slate-100 hover:bg-slate-800 transition-colors'
export function Wordmark() {
return (
<span className="text-[1.0625rem] font-semibold tracking-tight text-ink">
net<span className="text-accent">Ork</span>
</span>
)
}
function NavDropdown({
label,
active,
children,
}: {
label: string
active: boolean
children: ReactNode
}) {
const linkCls = (active: boolean) =>
`text-sm transition-colors ${active ? 'text-ink' : 'text-ink-muted hover:text-ink'}`
function NavDropdown({ label, active, children }: { label: string; active: boolean; children: ReactNode }) {
const [open, setOpen] = useState(false)
const location = useLocation()
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
@@ -38,15 +37,14 @@ function NavDropdown({
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1 text-sm transition-colors ${
active ? 'text-slate-100' : 'text-slate-400 hover:text-slate-100'
}`}
aria-expanded={open}
className={`flex items-center gap-1 ${linkCls(active)}`}
>
{label}
<ChevronDownIcon className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
<ChevronDownIcon className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute top-full mt-2 left-0 w-52 rounded-lg border border-slate-800 bg-slate-900 py-1 shadow-xl">
<div className="absolute left-0 top-full mt-3 w-56 rounded-lg border border-line bg-paper py-1.5 shadow-lg">
{children}
</div>
)}
@@ -54,89 +52,88 @@ function NavDropdown({
)
}
const dropdownItemCls = 'block px-4 py-2 text-sm text-ink-soft transition-colors hover:bg-paper-2 hover:text-ink'
const mobileItemCls = 'block rounded-md px-3 py-2 text-ink-soft transition-colors hover:bg-paper-2 hover:text-ink'
const mobileHeadingCls = 'px-3 pb-1 pt-4 text-eyebrow font-semibold uppercase text-ink-faint'
export default function Nav() {
const location = useLocation()
const { pathname } = useLocation()
const { lang, setLang, t } = useLang()
const [menuOpen, setMenuOpen] = useState(false)
const linkCls = (path: string) =>
`text-sm transition-colors ${
location.pathname === path
? 'text-slate-100'
: 'text-slate-400 hover:text-slate-100'
}`
useEffect(() => {
setMenuOpen(false)
}, [pathname])
const isDocsActive =
location.pathname.startsWith('/docs') || location.pathname === '/nis2' || location.pathname === '/glossary'
const isForActive = location.pathname.startsWith('/for/') || location.pathname === '/nis2'
const personas = [
{ to: '/for/it-department', label: t.nav.forItems.itDepartment },
{ to: '/for/it-support', label: t.nav.forItems.itSupport },
{ to: '/for/msp', label: t.nav.forItems.msp },
]
const links = [
{ to: '/nis2', label: t.nav.nis2 },
{ to: '/plugins', label: t.nav.plugins },
{ to: '/pricing', label: t.nav.pricing },
]
return (
<nav className="sticky top-0 z-10 bg-slate-900/80 backdrop-blur border-b border-slate-800">
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center gap-8">
<Link to="/" className="flex items-center gap-2.5 mr-2">
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
<span className="font-semibold text-slate-100 tracking-tight">
net<span className="text-sky-400">Ork</span>
</span>
<nav className="sticky top-0 z-30 border-b border-line bg-paper/85 backdrop-blur">
<Container className="flex h-14 items-center gap-8">
<Link to="/" aria-label="netOrk" className="mr-2">
<Wordmark />
</Link>
<Link to="/features" className={linkCls('/features')}>
{t.nav.features}
</Link>
<Link to="/drivers" className={linkCls('/drivers')}>
{t.nav.drivers}
</Link>
<NavDropdown label={t.nav.docs} active={isDocsActive}>
<Link to="/docs/getting-started" className={dropdownItemCls}>
{t.nav.docsItems.gettingStarted}
</Link>
<Link to="/docs/architecture" className={dropdownItemCls}>
{t.nav.docsItems.architecture}
</Link>
<div className="my-1 border-t border-slate-800" />
<Link to="/nis2" className={dropdownItemCls}>
{t.nav.docsItems.nis2}
</Link>
<Link to="/glossary" className={dropdownItemCls}>
{t.nav.docsItems.glossary}
<div className="hidden items-center gap-7 lg:flex">
<NavDropdown label={t.nav.forWhom} active={pathname.startsWith('/for/')}>
{personas.map((p) => (
<Link key={p.to} to={p.to} className={dropdownItemCls}>
{p.label}
</Link>
))}
</NavDropdown>
<NavDropdown label={t.nav.forWhom} active={isForActive}>
<Link to="/for/it-department" className={dropdownItemCls}>
{t.nav.forItems.itDepartment}
</Link>
<Link to="/for/it-support" className={dropdownItemCls}>
{t.nav.forItems.itSupport}
</Link>
<Link to="/for/msp" className={dropdownItemCls}>
{t.nav.forItems.msp}
</Link>
<div className="my-1 border-t border-slate-800" />
<Link to="/nis2" className={dropdownItemCls}>
{t.nav.docsItems.nis2}
</Link>
</NavDropdown>
<Link to="/plugins" className={linkCls('/plugins')}>
{t.nav.plugins}
</Link>
<Link to="/roadmap" className={linkCls('/roadmap')}>
{t.nav.roadmap}
{links.map((l) => (
<Link key={l.to} to={l.to} className={linkCls(pathname === l.to)}>
{l.label}
</Link>
))}
</div>
<div className="ml-auto flex items-center gap-4">
<LangSwitch lang={lang} setLang={setLang} />
<Link
to="/docs/getting-started"
className="inline-flex items-center gap-2 px-4 py-1.5 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
<ButtonLink to="/pricing" className="hidden h-9 px-4 sm:inline-flex">
{t.nav.buy}
</ButtonLink>
<button
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-label={t.nav.menu}
className="-mr-1 p-1 text-ink-muted transition-colors hover:text-ink lg:hidden"
>
{t.nav.getStarted}
{menuOpen ? <XMarkIcon className="h-6 w-6" /> : <Bars3Icon className="h-6 w-6" />}
</button>
</div>
</Container>
{menuOpen && (
<div className="max-h-[calc(100vh-3.5rem)] overflow-y-auto border-t border-line bg-paper lg:hidden">
<Container className="py-3">
{links.map((l) => (
<Link key={l.to} to={l.to} className={mobileItemCls}>
{l.label}
</Link>
))}
<p className={mobileHeadingCls}>{t.nav.forWhom}</p>
{personas.map((p) => (
<Link key={p.to} to={p.to} className={mobileItemCls}>
{p.label}
</Link>
))}
<ButtonLink to="/pricing" className="mt-4 w-full sm:hidden">
{t.nav.buy}
</ButtonLink>
</Container>
</div>
</div>
)}
</nav>
)
}
@@ -144,19 +141,18 @@ export default function Nav() {
function LangSwitch({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
return (
<div className="flex items-center gap-1 text-xs font-medium">
{(['de', 'en'] as const).map((l, i) => (
<span key={l} className="flex items-center gap-1">
{i > 0 && <span className="text-line-strong">/</span>}
<button
onClick={() => setLang('de')}
className={`transition-colors ${lang === 'de' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
onClick={() => setLang(l)}
aria-pressed={lang === l}
className={`uppercase transition-colors ${lang === l ? 'text-ink' : 'text-ink-faint hover:text-ink-soft'}`}
>
DE
</button>
<span className="text-slate-700">/</span>
<button
onClick={() => setLang('en')}
className={`transition-colors ${lang === 'en' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
>
EN
{l}
</button>
</span>
))}
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
/** A new page starts at the top; a link with a #hash lands on that section. */
export default function ScrollManager() {
const { pathname, hash } = useLocation()
useEffect(() => {
if (hash) {
// The target may render in the same commit; wait a frame so it exists.
requestAnimationFrame(() => {
document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView({ block: 'start' })
})
return
}
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
}, [pathname, hash])
return null
}
+44
View File
@@ -0,0 +1,44 @@
import sizes from '../data/screenshots.json'
type Name = keyof typeof sizes
/**
* A real screenshot of netOrk on the dark stage. Images come from
* scripts/screenshots/capture.py, which also writes their sizes to
* src/data/screenshots.json so the page reserves the right space.
*/
export default function Shot({
name,
narrow,
alt,
caption,
eager = false,
}: {
name: Name
/** A crop that reads on phones; shown below md instead of `name`. */
narrow?: Name
alt: string
caption?: string
eager?: boolean
}) {
const { width, height } = sizes[name]
return (
<figure>
<div className="rounded-2xl bg-night p-1 shadow-stage ring-1 ring-ink/10 sm:p-1.5">
<picture>
{narrow && <source media="(max-width: 767px)" srcSet={`/screenshots/${narrow}.webp`} />}
<img
src={`/screenshots/${name}.webp`}
alt={alt}
width={width}
height={height}
loading={eager ? 'eager' : 'lazy'}
decoding="async"
className="block h-auto w-full rounded-xl"
/>
</picture>
</div>
{caption && <figcaption className="mt-3 text-sm text-ink-muted">{caption}</figcaption>}
</figure>
)
}
+195
View File
@@ -0,0 +1,195 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
// The site's whole layout vocabulary. docs/DESIGN.md explains when to use which;
// if a page needs something that is not here, it probably needs less instead.
export function Container({ children, className = '' }: { children: ReactNode; className?: string }) {
return <div className={`mx-auto w-full max-w-page px-5 sm:px-8 ${className}`}>{children}</div>
}
/** A page section. `band` sets it on the slightly darker paper with hairlines above and below. */
export function Section({
id,
band = false,
className = '',
children,
}: {
id?: string
band?: boolean
className?: string
children: ReactNode
}) {
return (
<section
id={id}
className={`scroll-mt-20 py-20 md:py-28 ${band ? 'border-y border-line bg-paper-2' : ''} ${className}`}
>
<Container>{children}</Container>
</section>
)
}
export function Eyebrow({ children }: { children: ReactNode }) {
return <p className="mb-4 text-eyebrow font-semibold uppercase text-accent">{children}</p>
}
/** The top of every page except Home: eyebrow, one h1, a lead paragraph, optional actions. */
export function PageHeader({
eyebrow,
title,
lead,
children,
}: {
eyebrow?: ReactNode
title: ReactNode
lead?: ReactNode
children?: ReactNode
}) {
return (
<header className="pt-16 pb-12 md:pt-24 md:pb-16">
<Container>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<h1 className="max-w-3xl text-h1">{title}</h1>
{lead && <p className="mt-6 max-w-measure text-lead text-ink-soft">{lead}</p>}
{children && <div className="mt-8 flex flex-wrap gap-3">{children}</div>}
</Container>
</header>
)
}
const buttonBase =
'inline-flex h-11 items-center justify-center gap-2 rounded-lg px-5 text-sm font-medium transition-colors'
const buttonVariant = {
primary: 'bg-ink text-paper hover:bg-ink-soft',
secondary: 'border border-line-strong bg-paper text-ink hover:bg-paper-2',
}
/** Internal paths go through the router; anything with a scheme is a plain link. */
export function ButtonLink({
to,
variant = 'primary',
className = '',
children,
}: {
to: string
variant?: keyof typeof buttonVariant
className?: string
children: ReactNode
}) {
const cls = `${buttonBase} ${buttonVariant[variant]} ${className}`
if (/^[a-z]+:/.test(to)) {
return (
<a href={to} className={cls}>
{children}
</a>
)
}
return (
<Link to={to} className={cls}>
{children}
</Link>
)
}
export function TextLink({ to, children }: { to: string; children: ReactNode }) {
const cls = 'font-medium text-accent transition-colors hover:text-accent-hover'
if (/^[a-z]+:/.test(to)) {
return (
<a href={to} className={cls}>
{children} <span aria-hidden="true">→</span>
</a>
)
}
return (
<Link to={to} className={cls}>
{children} <span aria-hidden="true">→</span>
</Link>
)
}
/** Heading on the left five columns, content on the right: the default section layout. */
export function Split({ aside, children }: { aside: ReactNode; children: ReactNode }) {
return (
<div className="grid gap-10 lg:grid-cols-12 lg:gap-x-8">
<div className="lg:col-span-5">{aside}</div>
<div className="lg:col-span-7 lg:col-start-6 xl:col-span-6 xl:col-start-7">{children}</div>
</div>
)
}
export function H2({ children }: { children: ReactNode }) {
return <h2 className="text-h2">{children}</h2>
}
export type RuleItem = { term: ReactNode; body: ReactNode; aside?: ReactNode }
/** Rows divided by hairlines — what card grids used to be. Two columns from md up when `columns={2}`. */
export function RuleList({ items, columns = 1 }: { items: RuleItem[]; columns?: 1 | 2 }) {
return (
<dl className={columns === 2 ? 'grid gap-x-12 md:grid-cols-2' : ''}>
{items.map((item, i) => (
<div key={i} className="border-t border-line py-5">
<dt className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 text-h3 text-ink">
<span>{item.term}</span>
{item.aside}
</dt>
<dd className="mt-1 text-ink-soft">{item.body}</dd>
</div>
))}
</dl>
)
}
export function Steps({ items }: { items: { title: ReactNode; body: ReactNode }[] }) {
return (
<ol>
{items.map((item, i) => (
<li key={i} className="grid grid-cols-[3rem_1fr] border-t border-line py-6">
<span className="font-mono text-sm text-accent">{String(i + 1).padStart(2, '0')}</span>
<div>
<p className="text-h3 text-ink">{item.title}</p>
<p className="mt-1 text-ink-soft">{item.body}</p>
</div>
</li>
))}
</ol>
)
}
/** Closing call to action, on the band. */
export function CtaBand({ title, body, action }: { title: ReactNode; body?: ReactNode; action: ReactNode }) {
return (
<Section band>
<div className="flex flex-col gap-8 md:flex-row md:items-end md:justify-between">
<div className="max-w-2xl">
<H2>{title}</H2>
{body && <p className="mt-4 text-lead text-ink-soft">{body}</p>}
</div>
<div className="shrink-0">{action}</div>
</div>
</Section>
)
}
/** Code sits on the same dark stage as the product. */
export function CodeBlock({ code }: { code: string }) {
return (
<pre className="overflow-x-auto rounded-2xl bg-night p-6 font-mono text-sm leading-relaxed text-slate-200 shadow-stage">
<code>{code}</code>
</pre>
)
}
export type Coverage = 'covered' | 'partial' | 'na'
/** A dot and a word; colour alone never carries the meaning. */
export function Status({ coverage, label }: { coverage: Coverage; label: string }) {
const dot = { covered: 'bg-sync', partial: 'bg-drift', na: 'bg-ink-faint' }[coverage]
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium text-ink-soft">
<span className={`h-2 w-2 rounded-full ${dot}`} aria-hidden="true" />
{label}
</span>
)
}
+21 -5
View File
@@ -9,18 +9,34 @@ interface LangContextValue {
const LangContext = createContext<LangContextValue | null>(null)
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(() => {
// A stored choice wins; otherwise the browser decides: German for de-*, English for everything else.
function initialLang(): Lang {
try {
const stored = localStorage.getItem('lang')
return stored === 'de' || stored === 'en' ? stored : 'de'
})
if (stored === 'de' || stored === 'en') return stored
} catch {
// storage blocked: fall through to the browser language
}
const preferred = navigator.languages?.[0] ?? navigator.language ?? 'en'
return preferred.toLowerCase().startsWith('de') ? 'de' : 'en'
}
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(initialLang)
// <html lang> drives hyphenation and screen readers.
useEffect(() => {
localStorage.setItem('lang', lang)
document.documentElement.lang = lang
}, [lang])
// Only an explicit switch is remembered, so the browser keeps deciding until someone chooses.
function setLang(l: Lang) {
setLangState(l)
try {
localStorage.setItem('lang', l)
} catch {
// storage blocked: the choice lasts for this visit
}
}
return (
+26
View File
@@ -0,0 +1,26 @@
// Licence plans as the licence server defines them. Keep in step with
// license-server/netork_license/modules/{kb,registry}/__init__.py (plan defaults)
// and docs/PRODUCT.md. Prices are not published: every plan is "on request".
//
// The KB request limit (60 / 600 / 6000) is deliberately missing: its unit is
// not defined yet and nothing enforces it.
export const PORTAL_URL = 'https://license.netork.io/portal'
export type PlanId = 'starter' | 'pro' | 'enterprise'
export type Plan = {
id: PlanId
name: string
vulnData: boolean
evidence: boolean
cwe: boolean
historyDays: 90 | 365 | 3650
edge: boolean
}
export const PLANS: Plan[] = [
{ id: 'starter', name: 'Starter', vulnData: true, evidence: false, cwe: false, historyDays: 90, edge: false },
{ id: 'pro', name: 'Pro', vulnData: true, evidence: true, cwe: true, historyDays: 365, edge: false },
{ id: 'enterprise', name: 'Enterprise', vulnData: true, evidence: true, cwe: true, historyDays: 3650, edge: true },
]
+18
View File
@@ -0,0 +1,18 @@
{
"audit-log": {
"width": 2400,
"height": 1000
},
"drift": {
"width": 2284,
"height": 400
},
"drift-narrow": {
"width": 1200,
"height": 183
},
"vulnerabilities": {
"width": 2400,
"height": 1107
}
}
+33
View File
@@ -286,6 +286,39 @@ export const GLOSSARY: GlossaryEntry[] = [
},
match: ['MFA'],
},
{
id: 'kea',
category: 'networking',
display: 'Kea',
fullName: { en: 'ISC Kea DHCP', de: 'ISC Kea DHCP' },
definition: {
en: 'The DHCP server from ISC that OPNsense uses to hand out addresses; netOrk manages its subnets and reservations.',
de: 'Der DHCP-Server von ISC, mit dem OPNsense Adressen vergibt; netOrk verwaltet seine Subnetze und Reservierungen.',
},
match: ['Kea'],
},
{
id: 'winrm',
category: 'networking',
display: 'WinRM',
fullName: { en: 'Windows Remote Management', de: 'Windows Remote Management' },
definition: {
en: 'Microsoft\'s remote management protocol for Windows hosts, the Windows counterpart to SSH for automation.',
de: 'Microsofts Protokoll zur Fernverwaltung von Windows-Hosts, das Windows-Gegenstück zu SSH für Automatisierung.',
},
match: ['WinRM'],
},
{
id: 'lapi',
category: 'security',
display: 'LAPI',
fullName: { en: 'CrowdSec Local API', de: 'CrowdSec Local API' },
definition: {
en: 'The CrowdSec service that collects what its agents detect and holds the resulting ban decisions for one site or host.',
de: 'Der CrowdSec-Dienst, der sammelt, was seine Agenten erkennen, und die daraus folgenden Sperrentscheidungen für einen Standort oder Host hält.',
},
match: ['LAPI'],
},
{
id: 'nvd',
category: 'security',
+510 -776
View File
File diff suppressed because it is too large Load Diff
+30 -2
View File
@@ -2,6 +2,34 @@
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-950 text-slate-100;
@layer base {
html {
@apply bg-paper text-ink antialiased;
font-feature-settings: 'cv11', 'ss01';
}
body {
@apply bg-paper text-ink;
}
::selection {
@apply bg-accent-soft text-ink;
}
:focus-visible {
@apply outline-none ring-2 ring-accent ring-offset-2 ring-offset-paper;
}
h1,
h2,
h3 {
text-wrap: balance;
hyphens: auto;
}
p,
li,
dd {
text-wrap: pretty;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { useEffect } from 'react'
/** Sets the browser tab title. Titles come complete from translations (`meta`). */
export function usePageTitle(title: string) {
useEffect(() => {
document.title = title
}, [title])
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '@fontsource-variable/inter'
import './index.css'
import App from './App'
import { LangProvider } from './context/LangContext'
-125
View File
@@ -1,125 +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', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'procurve', deviceType: 'HP ProCurve / Aruba switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'linux', deviceType: 'Generic Linux servers', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'netgear', deviceType: 'Netgear 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 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: 'proxmox', deviceType: 'Proxmox VE hypervisors', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
{ 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: 'zyxel', deviceType: 'Zyxel switches', 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>
</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>
)
}
-40
View File
@@ -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>
)
}
-120
View File
@@ -1,120 +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: '11 built-in roles (base, docker, adguard, wireguard, fail2ban, 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 13 widgets on a WYSIWYG grid — replace the fixed layout with the view your team actually needs.' },
],
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: '11 eingebaute Rollen (base, docker, adguard, wireguard, fail2ban 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 13 Widgets auf einem WYSIWYG-Grid — statt festem Layout die Ansicht, die euer Team wirklich braucht.' },
],
}
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-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 11 device types, plus every built-in NAPALM driver.'
: 'Eigene NAPALM-Treiber für 11 Gerätetypen, plus alle 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>
)
}
-106
View File
@@ -1,106 +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.' },
],
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.' },
],
}
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-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>
)
}
-106
View File
@@ -1,106 +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.' },
],
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.' },
],
}
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-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>
)
}
+15 -30
View File
@@ -1,39 +1,24 @@
import { Link } from 'react-router-dom'
import { EnvelopeIcon } from '@heroicons/react/24/outline'
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { CONTACT_EMAIL } from '../i18n/translations'
import { ButtonLink, PageHeader } from '../components/ui'
export default function GettingStarted() {
const { t } = useLang()
const g = t.gettingStarted
usePageTitle(t.meta.gettingStarted)
return (
<div className="py-24 md:py-40">
<div className="max-w-2xl mx-auto px-6 text-center">
<p className="text-sm font-medium text-sky-400 mb-4 font-mono">{t.gettingStarted.label}</p>
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 leading-tight mb-6 whitespace-pre-line">
{t.gettingStarted.heading}
</h1>
<p className="text-base text-slate-400 leading-relaxed mb-10">
{t.gettingStarted.sub}
</p>
<a
href="mailto:christian@manivong.de"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
>
<EnvelopeIcon className="h-4 w-4" />
{t.gettingStarted.cta}
</a>
<div className="mt-12 border-t border-slate-800 pt-8">
<p className="text-sm text-slate-500 mb-4">{t.gettingStarted.bottomText}</p>
<div className="flex gap-6 justify-center">
<Link to="/features" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
{t.gettingStarted.bottomFeatures}
</Link>
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
{t.gettingStarted.bottomDrivers}
</Link>
</div>
</div>
</div>
<div className="pb-24">
<PageHeader eyebrow={g.eyebrow} title={g.title} lead={g.lead}>
<ButtonLink to={`mailto:${CONTACT_EMAIL}`}>{g.cta}</ButtonLink>
<ButtonLink to="/#how" variant="secondary">
{g.how}
</ButtonLink>
<ButtonLink to="/pricing" variant="secondary">
{g.pricing}
</ButtonLink>
</PageHeader>
</div>
)
}
+41 -37
View File
@@ -1,56 +1,60 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { GLOSSARY, CATEGORY_ORDER, CATEGORY_LABELS } from '../glossary/terms'
import { Container, PageHeader } from '../components/ui'
export default function Glossary() {
const { lang, t } = useLang()
const location = useLocation()
usePageTitle(t.meta.glossary)
useEffect(() => {
const id = location.hash.slice(1)
if (!id) return
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, [location.hash])
const categories = CATEGORY_ORDER.map((category) => ({
category,
entries: GLOSSARY.filter((e) => e.category === category).sort((a, b) => a.display.localeCompare(b.display)),
})).filter((c) => c.entries.length > 0)
return (
<div className="py-16 md:py-24">
<div className="max-w-4xl mx-auto px-6">
<div className="mb-16">
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.glossary.heading}</h1>
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{t.glossary.sub}</p>
</div>
<>
<PageHeader title={t.glossary.title} lead={t.glossary.lead} />
<div className="space-y-14">
{CATEGORY_ORDER.map((category) => {
const entries = GLOSSARY.filter((e) => e.category === category).sort((a, b) =>
a.display.localeCompare(b.display),
)
if (entries.length === 0) return null
return (
<div key={category}>
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
<Container className="pb-24">
<div className="grid gap-10 lg:grid-cols-12 lg:gap-x-8">
{/* Category index, sticky beside the list from lg up. */}
<nav className="lg:col-span-3">
<ul className="flex flex-wrap gap-x-5 gap-y-2 text-sm lg:sticky lg:top-24 lg:flex-col">
{categories.map(({ category }) => (
<li key={category}>
<a href={`#cat-${category}`} className="text-ink-muted transition-colors hover:text-ink">
{CATEGORY_LABELS[category][lang]}
</h2>
<dl className="space-y-6">
</a>
</li>
))}
</ul>
</nav>
<div className="space-y-16 lg:col-span-9">
{categories.map(({ category, entries }) => (
<section key={category} id={`cat-${category}`} className="scroll-mt-24">
<h2 className="text-h3 text-ink-muted">{CATEGORY_LABELS[category][lang]}</h2>
<dl className="mt-4">
{entries.map((entry) => (
<div key={entry.id} id={entry.id} className="scroll-mt-24">
<dt className="flex flex-wrap items-baseline gap-2 mb-1">
<span className="font-mono text-sm font-semibold text-sky-400">{entry.display}</span>
{entry.fullName && (
<span className="text-sm text-slate-300">{entry.fullName[lang]}</span>
)}
<div
key={entry.id}
id={entry.id}
className="grid scroll-mt-24 gap-x-8 gap-y-1 border-t border-line py-4 sm:grid-cols-[12rem_1fr]"
>
<dt>
<span className="font-mono text-sm font-semibold text-ink">{entry.display}</span>
{entry.fullName && <span className="block text-sm text-ink-muted">{entry.fullName[lang]}</span>}
</dt>
<dd className="text-sm text-slate-500 leading-relaxed">{entry.definition[lang]}</dd>
<dd className="text-ink-soft">{entry.definition[lang]}</dd>
</div>
))}
</dl>
</div>
)
})}
</div>
</section>
))}
</div>
</div>
</Container>
</>
)
}
+91 -532
View File
@@ -1,553 +1,112 @@
import { Link } from 'react-router-dom'
import {
MagnifyingGlassIcon,
ChartBarIcon,
WrenchScrewdriverIcon,
PuzzlePieceIcon,
ShieldCheckIcon,
} from '@heroicons/react/24/outline'
import { useLang } from '../context/LangContext'
import { linkify } from '../lib/glossary'
const DRIVERS = [
'Arista EOS', 'Cisco IOS', 'Cisco IOS-XE', 'Cisco NX-OS', 'Fritz!Box',
'HP ProCurve / Aruba', 'Juniper JunOS', 'Linux', 'Netgear', 'OpenMediaVault',
'OpenWRT', 'OPNsense', 'Proxmox VE', 'Sonos', 'TP-Link Jetstream', 'Zyxel',
]
function BrowserFrame({ label, children }: { label: string; children: React.ReactNode }) {
return (
<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">
<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-green-500/70" />
<span className="ml-4 text-xs text-slate-500 font-mono">{label}</span>
</div>
{children}
</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>
)
}
import { usePageTitle } from '../lib/usePageTitle'
import Shot from '../components/Shot'
import {
ButtonLink,
Container,
CtaBand,
Eyebrow,
H2,
RuleList,
Section,
Split,
Steps,
TextLink,
} from '../components/ui'
export default function Home() {
const { t } = useLang()
const h = t.home
usePageTitle(t.meta.home)
return (
<>
{/* 1 — Hero */}
<section className="py-24 md:py-32">
<div className="max-w-7xl mx-auto px-6 text-center">
<h1 className="text-4xl md:text-6xl font-bold text-slate-100 leading-tight mb-6">
{h.hero.line1}<br />
<span className="text-sky-400">{h.hero.line2}</span>
</h1>
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mx-auto mb-10">
{linkify(h.hero.sub)}
{/* The message, and the drift itself as proof. */}
<header className="pt-16 md:pt-24">
<Container>
<Eyebrow>{h.hero.eyebrow}</Eyebrow>
<h1 className="max-w-4xl text-display">{h.hero.title}</h1>
<p className="mt-6 max-w-2xl text-lead text-ink-soft">{h.hero.lead}</p>
<div className="mt-8 flex flex-wrap gap-3">
<ButtonLink to="/pricing">{h.hero.primary}</ButtonLink>
<ButtonLink to="/#how" variant="secondary">
{h.hero.secondary}
</ButtonLink>
</div>
<div className="mt-14 md:mt-20">
<Shot name="drift" narrow="drift-narrow" alt={t.shots.drift} caption={h.hero.caption} eager />
</div>
</Container>
</header>
<Section id="how">
<Split
aside={
<>
<Eyebrow>{h.how.eyebrow}</Eyebrow>
<H2>{h.how.title}</H2>
<p className="mt-6 text-ink-soft">{h.how.intro}</p>
</>
}
>
<Steps items={h.how.steps} />
</Split>
</Section>
<Section id="hardware" band>
<Split
aside={
<>
<Eyebrow>{h.hardware.eyebrow}</Eyebrow>
<H2>{h.hardware.title}</H2>
</>
}
>
<RuleList items={h.hardware.groups.map((g) => ({ term: g.title, body: g.body }))} />
<p className="mt-6 border-t border-line pt-5 text-sm text-ink-muted">{h.hardware.note}</p>
</Split>
</Section>
<Section>
<div className="max-w-2xl">
<Eyebrow>{h.vulns.eyebrow}</Eyebrow>
<H2>{h.vulns.title}</H2>
<p className="mt-6 text-lead text-ink-soft">{h.vulns.body}</p>
<p className="mt-6">
<TextLink to="/pricing">{h.vulns.link}</TextLink>
</p>
<div className="flex flex-wrap gap-4 justify-center">
<a
href="mailto:christian@manivong.de"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
>
{h.hero.cta1}
</a>
<Link
to="/features"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-slate-700 hover:border-slate-500 text-slate-300 hover:text-slate-100 transition-colors"
>
{h.hero.cta2}
</Link>
</div>
<div className="mt-12">
<Shot name="vulnerabilities" alt={t.shots.vulnerabilities} caption={h.vulns.caption} />
</div>
<div className="max-w-7xl mx-auto px-6 mt-16">
<BrowserFrame label="netork.local / devices">
<MockDeviceList />
</BrowserFrame>
</div>
</section>
</Section>
<div className="border-t border-slate-800" />
{/* 2 — Problem Statement */}
<section className="py-16">
<div className="max-w-3xl mx-auto px-6 text-center">
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.problem)}</p>
<Section id="included" className="pt-0 md:pt-0">
<div className="border-t border-line-strong pt-16 md:pt-20">
<H2>{h.included.title}</H2>
<div className="mt-10">
<RuleList
columns={2}
items={h.included.items.map((i) => ({ term: i.title, body: i.body }))}
/>
</div>
</section>
<div className="border-t border-slate-800" />
{/* 3 — Core Capabilities */}
<section className="py-24">
<div className="max-w-7xl mx-auto px-6">
<div className="grid md:grid-cols-3 gap-6">
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
<MagnifyingGlassIcon className="h-5 w-5 text-sky-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap1.title}</h3>
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap1.body)}</p>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
<ChartBarIcon className="h-5 w-5 text-sky-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap2.title}</h3>
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap2.body)}</p>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
<WrenchScrewdriverIcon className="h-5 w-5 text-sky-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap3.title}</h3>
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap3.body)}</p>
</div>
</div>
</div>
</section>
<div className="border-t border-slate-800" />
{/* 4 — Driver Grid */}
<section className="py-24">
<div className="max-w-7xl mx-auto px-6 text-center">
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.driversHeading}</h2>
<p className="text-base text-slate-400 leading-relaxed max-w-xl mx-auto mb-10">{linkify(h.driversSub)}</p>
<div className="flex flex-wrap justify-center gap-3">
{DRIVERS.map((d) => (
<span key={d} 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">
{d}
</span>
))}
</div>
<div className="mt-8">
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
{h.driversLink}
</Link>
</div>
</div>
</section>
<div className="border-t border-slate-800" />
{/* 5 — Screenshot Walkthrough */}
<section className="py-24">
<div className="max-w-7xl mx-auto px-6 space-y-24">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="mt-12 grid gap-4 border-t border-line pt-6 sm:grid-cols-[8rem_1fr]">
<p className="text-eyebrow font-semibold uppercase text-accent">{h.nis2.label}</p>
<div>
<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>
</div>
<BrowserFrame label="netork.local / devices / ap-001">
<MockDeviceDetail />
</BrowserFrame>
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
<BrowserFrame label="netork.local / vlans">
<MockVlans />
</BrowserFrame>
<div>
<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="max-w-2xl text-ink-soft">{h.nis2.body}</p>
<p className="mt-3">
<TextLink to="/nis2">{h.nis2.link}</TextLink>
</p>
</div>
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<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>
</div>
<BrowserFrame label="netork.local / devices / ap-001 / security">
<MockSecurity />
</BrowserFrame>
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
<BrowserFrame label="netork.local / devices / fw-001 / config">
<MockConfigDiff />
</BrowserFrame>
<div>
<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>
</div>
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<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>
</div>
<BrowserFrame label="netork.local / dashboards / my-dashboard">
<MockDashboard />
</BrowserFrame>
</div>
</div>
</section>
</Section>
<div className="border-t border-slate-800" />
{/* 5b — NIS2 */}
<section className="py-24">
<div className="max-w-7xl mx-auto px-6">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<p className="text-xs font-semibold tracking-widest uppercase text-sky-500 mb-3">{h.nis2Label}</p>
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.nis2Heading}</h2>
<p className="text-base text-slate-400 leading-relaxed mb-8">{linkify(h.nis2Body)}</p>
<div className="mb-8">
<Link to="/nis2" className="inline-flex items-center gap-2 text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
<ShieldCheckIcon className="h-4 w-4" />
{h.nis2Link}
</Link>
</div>
<div className="space-y-4">
{h.nis2Items.map((m) => (
<div key={m.art} className="flex gap-4">
<span className="mt-0.5 shrink-0 w-28 text-xs font-mono text-sky-500">{m.art}</span>
<div>
<p className="text-sm font-medium text-slate-200">{m.label}</p>
<p className="text-xs text-slate-500 mt-0.5">{linkify(m.detail)}</p>
</div>
</div>
))}
</div>
</div>
<BrowserFrame label="netork.local / compliance / HQ">
<MockCompliance />
</BrowserFrame>
</div>
</div>
</section>
<div className="border-t border-slate-800" />
{/* 6 — Plugin System */}
<section className="py-24">
<div className="max-w-7xl mx-auto px-6">
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 md:p-12">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
</div>
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.pluginsHeading}</h2>
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mb-6">{linkify(h.pluginsBody)}</p>
<Link to="/plugins" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
{h.pluginsLink}
</Link>
</div>
</div>
</section>
<div className="border-t border-slate-800" />
{/* 7 — Deployment */}
<section className="py-24">
<div className="max-w-7xl mx-auto px-6">
<div className="grid md:grid-cols-2 gap-12 items-start">
<div>
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.deployHeading}</h2>
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.deployBody)}</p>
</div>
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6 font-mono text-sm text-slate-300 overflow-x-auto">
<code>{`# Clone + configure
git clone https://gitea.example.com/netork/netork.git
cp .env.example .env
# edit .env (DB URL, Redis password, secret key)
# Deploy
bash scripts/deploy.sh 192.168.1.10`}</code>
</pre>
</div>
</div>
</section>
<div className="border-t border-slate-800" />
{/* 8 — CTA */}
<section className="py-24">
<div className="max-w-7xl mx-auto px-6 text-center">
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.ctaHeading}</h2>
<p className="text-base text-slate-400 leading-relaxed mb-8">{h.ctaBody}</p>
<a
href="mailto:christian@manivong.de"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
>
{h.ctaButton}
</a>
</div>
</section>
<CtaBand
title={h.cta.title}
body={h.cta.body}
action={<ButtonLink to="/pricing">{h.cta.button}</ButtonLink>}
/>
</>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { Container, PageHeader } from '../components/ui'
/**
* Impressum and Datenschutz. Placeholders until the owner supplies the text;
* netork.io must not go live with the placeholder box still showing.
*/
export default function Legal({ page }: { page: 'imprint' | 'privacy' }) {
const { t } = useLang()
const l = t.legal[page]
usePageTitle(t.meta[page])
return (
<div className="pb-24">
<PageHeader title={l.title} />
<Container>
<div className="max-w-measure rounded-lg border border-drift/40 bg-drift-soft p-5">
<p className="text-sm font-semibold text-drift">{t.legal.placeholder}</p>
<p className="mt-2 text-ink-soft">{l.body}</p>
</div>
</Container>
</div>
)
}
+40 -218
View File
@@ -1,235 +1,57 @@
import { Link } from 'react-router-dom'
import { ShieldCheckIcon } from '@heroicons/react/24/outline'
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { linkify } from '../lib/glossary'
type Coverage = 'covered' | 'partial' | 'roadmap' | 'na'
type Requirement = { article: string; label: string; coverage: Coverage; netork: string }
type EvidenceBlock = { trigger: string; produces: string[] }
type ComingItem = { title: string; detail: string }
const REQUIREMENTS: Record<'en' | 'de', Requirement[]> = {
en: [
{ article: 'Art. 21 (2a)', label: 'Risk analysis & information system security policies', coverage: 'partial', netork: 'Config drift detection, SNMP health metrics, and security agent coverage across all devices provide a continuous risk baseline. A formal risk register is out of scope for netOrk.' },
{ article: 'Art. 21 (2b)', label: 'Incident handling', coverage: 'partial', netork: 'Wazuh alert history, CrowdSec decisions, and Graylog syslog per device surface incidents at the network layer. A structured incident record with NIS2 Art. 23 reporting timers is on the roadmap.' },
{ article: 'Art. 21 (2c)', label: 'Business continuity, backup management, disaster recovery', coverage: 'partial', netork: 'Every poll captures a configuration snapshot into a local Git repository — full history, a side-by-side diff viewer between any two points in time, and one-click restore for OPNsense. Backup/recovery for full device state beyond configuration is out of scope.' },
{ article: 'Art. 21 (2d)', label: 'Supply chain security', coverage: 'covered', netork: 'Vendor, model, firmware, and OS version are tracked per device after every poll. The EOL Tracking plugin checks each device\'s OS version against the endoflife.date API daily and flags unsupported or soon-to-be-unsupported software.' },
{ article: 'Art. 21 (2e)', label: 'Vulnerability handling in acquisition, development & maintenance', coverage: 'covered', netork: 'Per-device update status and installed package list tracked on every poll. Wazuh CVE counts by severity (critical / high / medium) linked directly to each device record. CVE cross-reference against NVD/OSV (without Wazuh) is on the roadmap.' },
{ article: 'Art. 21 (2f)', label: 'Assessing effectiveness of cybersecurity measures', coverage: 'partial', netork: 'The audit log records all orchestration actions. A per-site compliance dashboard (on roadmap) will aggregate security agent coverage, drift status, and patch metrics into a single view.' },
{ article: 'Art. 21 (2g)', label: 'Basic cyber hygiene & cybersecurity training', coverage: 'na', netork: 'Out of scope for a network orchestration platform. Training and hygiene policies are handled at the organizational level.' },
{ article: 'Art. 21 (2h)', label: 'Access control, asset management, human resources security', coverage: 'covered', netork: 'Full device inventory maintained automatically via discovery and continuous polling. RBAC with four built-in roles (viewer / operator / engineer / administrator) and custom role combinations. Complete audit log of all orchestration actions, filterable by date range, user, action, or resource — export to CSV or PDF for audit submissions.' },
{ article: 'Art. 21 (2i)', label: 'Multi-factor authentication', coverage: 'covered', netork: 'TOTP-based MFA for netOrk user accounts — authenticator app at login, backup codes for emergencies, session invalidation on TOTP changes, enforceable per role.' },
{ article: 'Art. 21 (2j)', label: 'Physical and environmental security', coverage: 'na', netork: 'Out of scope. Physical security of the infrastructure hosting netOrk is an organizational and facility concern.' },
],
de: [
{ article: 'Art. 21 (2a)', label: 'Risikoanalyse und Sicherheitsrichtlinien für Informationssysteme', coverage: 'partial', netork: 'Konfigurationsdrift-Erkennung, SNMP-Gesundheitsmetriken und Security-Agent-Abdeckung über alle Geräte liefern eine kontinuierliche Risiko-Baseline. Ein formales Risikoregister liegt außerhalb des Scopes von netOrk.' },
{ article: 'Art. 21 (2b)', label: 'Bewältigung von Sicherheitsvorfällen', coverage: 'partial', netork: 'Wazuh-Alert-Historie, CrowdSec-Entscheidungen und Graylog-Syslog pro Gerät decken Vorfälle auf Netzwerkebene auf. Ein strukturierter Incident-Datensatz mit NIS2 Art. 23 Melde-Timern ist auf der Roadmap.' },
{ article: 'Art. 21 (2c)', label: 'Geschäftskontinuität, Backup-Management, Disaster Recovery', coverage: 'partial', netork: 'Bei jedem Poll wird ein Konfigurationssnapshot in einem lokalen Git-Repository gespeichert — vollständige Historie, ein Side-by-Side-Diff-Viewer zwischen beliebigen Zeitpunkten und Ein-Klick-Restore für OPNsense. Backup/Recovery für den vollständigen Gerätezustand über die Konfiguration hinaus liegt außerhalb des Scopes.' },
{ article: 'Art. 21 (2d)', label: 'Supply-Chain-Sicherheit', coverage: 'covered', netork: 'Hersteller, Modell, Firmware und OS-Version werden nach jedem Poll pro Gerät erfasst. Das EOL-Tracking-Plugin gleicht die OS-Version jedes Geräts täglich mit der endoflife.date-API ab und kennzeichnet nicht mehr oder bald nicht mehr unterstützte Software.' },
{ article: 'Art. 21 (2e)', label: 'Schwachstellenbehandlung bei Erwerb, Entwicklung & Wartung', coverage: 'covered', netork: 'Update-Status und installierte Paketliste pro Gerät werden bei jedem Poll erfasst. Wazuh-CVE-Anzahl nach Schweregrad (kritisch / hoch / mittel) direkt mit jedem Gerätedatensatz verknüpft. CVE-Abgleich gegen NVD/OSV (ohne Wazuh) ist auf der Roadmap.' },
{ article: 'Art. 21 (2f)', label: 'Beurteilung der Wirksamkeit von Cybersicherheitsmaßnahmen', coverage: 'partial', netork: 'Das Audit-Log erfasst alle Orchestrierungsaktionen. Ein Compliance-Dashboard pro Standort (auf der Roadmap) wird Security-Agent-Abdeckung, Drift-Status und Patch-Metriken in einer Ansicht zusammenfassen.' },
{ article: 'Art. 21 (2g)', label: 'Grundlegende Cyberhygiene und Cybersicherheitsschulungen', coverage: 'na', netork: 'Außerhalb des Scopes einer Netzwerk-Orchestrierungsplattform. Schulungen und Hygiene-Richtlinien werden auf Organisationsebene gehandhabt.' },
{ article: 'Art. 21 (2h)', label: 'Zugangskontrolle, Asset-Management, Personalsicherheit', coverage: 'covered', netork: 'Vollständiges Geräteinventar automatisch über Discovery und kontinuierliches Polling gepflegt. RBAC mit vier integrierten Rollen (Betrachter / Operator / Ingenieur / Administrator) und benutzerdefinierten Rollenkombinationen. Vollständiges Audit-Log aller Orchestrierungsaktionen, filterbar nach Datumsbereich, Benutzer, Aktion oder Ressource — Export als CSV oder PDF für Audit-Einreichungen.' },
{ article: 'Art. 21 (2i)', label: 'Multi-Faktor-Authentifizierung', coverage: 'covered', netork: 'TOTP-basierte MFA für netOrk-Benutzerkonten — Authenticator-App beim Login, Backup-Codes für Notfälle, Session-Invalidierung bei TOTP-Änderungen, pro Rolle erzwingbar.' },
{ article: 'Art. 21 (2j)', label: 'Physische und umgebungsbezogene Sicherheit', coverage: 'na', netork: 'Außerhalb des Scopes. Die physische Sicherheit der Infrastruktur, die netOrk hostet, ist eine organisatorische und gebäudetechnische Angelegenheit.' },
],
}
const EVIDENCE: Record<'en' | 'de', EvidenceBlock[]> = {
en: [
{
trigger: 'Every device poll',
produces: [
'Installed package list with versions',
'Available update count',
'Interface status, ARP table, DHCP leases',
'VLAN membership vs. desired state (drift)',
'SNMP health metrics (CPU, memory, interface counters)',
'Wazuh agent status and CVE counts by severity',
'Graylog syslog forwarding status',
'CrowdSec decisions and ban counts',
'Git-backed configuration snapshot, diffed against the previous one to detect unauthorized changes',
],
},
{
trigger: 'Every user action',
produces: [
'Audit log entry: user, timestamp, resource, action',
'Before/after values for configuration changes',
'Acknowledged warnings logged with the accepting user',
],
},
{
trigger: 'On demand',
produces: [
'Topology graph — network segmentation view',
'Subnet browser — IP space coverage',
'VLAN matrix — which devices carry which VLANs',
'Configuration diff between any two snapshots; one-click restore (OPNsense)',
'Audit log export to PDF / CSV, filtered by date range, user, action, or resource',
],
},
],
de: [
{
trigger: 'Bei jedem Geräte-Poll',
produces: [
'Installierte Paketliste mit Versionen',
'Anzahl verfügbarer Updates',
'Schnittstellenstatus, ARP-Tabelle, DHCP-Leases',
'VLAN-Zugehörigkeit vs. Sollzustand (Drift)',
'SNMP-Gesundheitsmetriken (CPU, Speicher, Schnittstellenzähler)',
'Wazuh-Agent-Status und CVE-Anzahl nach Schweregrad',
'Graylog-Syslog-Weiterleitungsstatus',
'CrowdSec-Entscheidungen und Ban-Anzahl',
'Git-basierter Konfigurationssnapshot, gegen den vorherigen geprüft, um nicht autorisierte Änderungen zu erkennen',
],
},
{
trigger: 'Bei jeder Benutzeraktion',
produces: [
'Audit-Log-Eintrag: Benutzer, Zeitstempel, Ressource, Aktion',
'Vorher/Nachher-Werte für Konfigurationsänderungen',
'Bestätigte (acked) Warnungen werden mit dem bestätigenden Benutzer protokolliert',
],
},
{
trigger: 'Auf Abruf',
produces: [
'Topologie-Graph — Netzwerksegmentierungs-Ansicht',
'Subnetz-Browser — IP-Raum-Abdeckung',
'VLAN-Matrix — welche Geräte welche VLANs führen',
'Konfigurations-Diff zwischen zwei beliebigen Snapshots; Ein-Klick-Restore (OPNsense)',
'Audit-Log-Export als PDF / CSV, gefiltert nach Datumsbereich, Benutzer, Aktion oder Ressource',
],
},
],
}
const COMING: Record<'en' | 'de', ComingItem[]> = {
en: [
{ title: 'CVE tracking per device', detail: 'Automatic cross-reference of installed packages against NVD / OSV — no Wazuh agent required.' },
{ title: 'Compliance dashboard', detail: 'Per-site Art. 21 checklist: asset coverage, patch status, agent deployment, drift, syslog, audit activity.' },
{ title: 'Incident workflow', detail: 'Structured incident records with NIS2 Art. 23 reporting timers (24 h / 72 h) and external webhook delivery.' },
],
de: [
{ title: 'CVE-Tracking pro Gerät', detail: 'Automatischer Abgleich installierter Pakete gegen NVD / OSV — kein Wazuh-Agent erforderlich.' },
{ title: 'Compliance-Dashboard', detail: 'Art. 21-Checkliste pro Standort: Asset-Abdeckung, Patch-Status, Agent-Deployment, Drift, Syslog, Audit-Aktivität.' },
{ title: 'Incident-Workflow', detail: 'Strukturierte Incident-Datensätze mit NIS2 Art. 23 Melde-Timern (24 h / 72 h) und externer Webhook-Zustellung.' },
],
}
function CoverageTag({ coverage, labels }: { coverage: Coverage; labels: Record<string, string> }) {
const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap border'
if (coverage === 'covered') return <span className={`${base} bg-green-500/15 text-green-400 border-green-500/20`}>{labels.covered}</span>
if (coverage === 'partial') return <span className={`${base} bg-yellow-500/15 text-yellow-400 border-yellow-500/20`}>{labels.partial}</span>
if (coverage === 'roadmap') return <span className={`${base} bg-sky-500/15 text-sky-400 border-sky-500/20`}>{labels.roadmap}</span>
return <span className={`${base} bg-slate-700/50 text-slate-500 border-slate-700`}>{labels.na}</span>
}
import Shot from '../components/Shot'
import { ButtonLink, CtaBand, H2, PageHeader, Section, Split, Status } from '../components/ui'
export default function Nis2() {
const { lang, t } = useLang()
const reqs = REQUIREMENTS[lang]
const evidence = EVIDENCE[lang]
const coming = COMING[lang]
const coverageLabels = t.nis2.coverageLabels
const legendLabels = t.nis2.legendLabels
const { t } = useLang()
const n = t.nis2
usePageTitle(t.meta.nis2)
return (
<div className="py-16 md:py-24">
<div className="max-w-4xl mx-auto px-6">
<>
<PageHeader eyebrow={n.eyebrow} title={n.title} lead={linkify(n.lead)} />
<div className="mb-16">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
<ShieldCheckIcon className="h-5 w-5 text-sky-400" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.nis2.heading}</h1>
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.nis2.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">
{t.nis2.mappingHeading}
</h2>
<div className="space-y-0 rounded-xl border border-slate-800 overflow-hidden">
{reqs.map((r, i) => (
<div key={r.article} className={`p-5 ${i < reqs.length - 1 ? 'border-b border-slate-800' : ''}`}>
<div className="flex flex-wrap items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-sky-500 shrink-0">{r.article}</span>
<span className="text-sm font-medium text-slate-200">{r.label}</span>
</div>
<CoverageTag coverage={r.coverage} labels={coverageLabels} />
</div>
<p className="text-sm text-slate-500 leading-relaxed md:pl-24">{linkify(r.netork)}</p>
<Section className="pt-0 md:pt-0">
<H2>{n.tableTitle}</H2>
<dl className="mt-10">
{n.requirements.map((r) => (
<div
key={r.article}
className="grid gap-x-8 gap-y-2 border-t border-line py-5 md:grid-cols-[6rem_16rem_1fr_8rem]"
>
<dt className="font-mono text-sm text-accent">Art. 21 {r.article}</dt>
<dt className="text-h3 text-ink">{r.label}</dt>
<dd className="text-ink-soft">{linkify(r.body)}</dd>
<dd className="md:text-right">
<Status coverage={r.coverage} label={n.coverage[r.coverage]} />
</dd>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-500">
{(['covered', 'partial', 'roadmap', 'na'] as Coverage[]).map((c) => (
<span key={c} className="flex items-center gap-1.5">
<CoverageTag coverage={c} labels={coverageLabels} />
{legendLabels[c]}
</span>
))}
</div>
</div>
</dl>
</Section>
<div className="mb-20">
<h2 className="text-xl font-semibold text-slate-200 mb-2 pb-2 border-b border-slate-800">
{t.nis2.evidenceHeading}
</h2>
<p className="text-sm text-slate-500 mb-6">{linkify(t.nis2.evidenceSub)}</p>
<div className="space-y-6">
{evidence.map((e) => (
<div key={e.trigger} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
<p className="text-xs font-semibold text-sky-500 uppercase tracking-widest mb-3">{e.trigger}</p>
<ul className="space-y-1.5">
{e.produces.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm text-slate-400">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
{linkify(item)}
<Section band>
<Split aside={<H2>{n.evidenceTitle}</H2>}>
<ul>
{n.evidence.map((e) => (
<li key={e} className="border-t border-line py-4 text-ink-soft first:border-t-0 first:pt-0">
{linkify(e)}
</li>
))}
</ul>
</Split>
<div className="mt-14">
<Shot name="audit-log" alt={t.shots.auditLog} caption={n.auditCaption} />
</div>
))}
</div>
</div>
</Section>
<div className="mb-20">
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
{t.nis2.comingHeading}
</h2>
<div className="space-y-4">
{coming.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>
<p className="text-sm font-medium text-slate-200">{item.title}</p>
<p className="text-sm text-slate-500 mt-0.5">{linkify(item.detail)}</p>
</div>
</div>
))}
</div>
<div className="mt-8">
<Link to="/roadmap" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
{lang === 'de' ? 'Vollständige Roadmap →' : 'Full roadmap →'}
</Link>
</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.nis2.cta.heading}</h2>
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.nis2.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.nis2.cta.button}
</Link>
</div>
</div>
</div>
<CtaBand
title={t.personas.cta.title}
body={t.personas.cta.body}
action={<ButtonLink to="/pricing">{t.personas.cta.button}</ButtonLink>}
/>
</>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { linkify } from '../lib/glossary'
import Shot from '../components/Shot'
import { ButtonLink, Container, CtaBand, H2, PageHeader, RuleList, Section, Split } from '../components/ui'
export type PersonaId = 'itDepartment' | 'itSupport' | 'msp'
/** One template for the three "who it's for" pages; only the copy differs. */
export default function Persona({ id }: { id: PersonaId }) {
const { t } = useLang()
const shared = t.personas
const p = shared[id]
usePageTitle(p.title)
return (
<>
<PageHeader eyebrow={p.eyebrow} title={p.heading} lead={p.lead} />
{/* The primary audience sees the drift itself; the others read about it. */}
{id === 'itDepartment' && (
<Container className="pb-8">
<Shot name="drift" narrow="drift-narrow" alt={t.shots.drift} />
</Container>
)}
<Section>
<Split aside={<H2>{shared.problems}</H2>}>
<RuleList items={p.problems.map((i) => ({ term: i.title, body: linkify(i.body) }))} />
</Split>
</Section>
<Section className="pt-0 md:pt-0">
<Split aside={<H2>{shared.helps}</H2>}>
<RuleList items={p.helps.map((i) => ({ term: i.title, body: linkify(i.body) }))} />
</Split>
</Section>
<CtaBand
title={shared.cta.title}
body={shared.cta.body}
action={<ButtonLink to="/pricing">{shared.cta.button}</ButtonLink>}
/>
</>
)
}
+30 -228
View File
@@ -1,250 +1,52 @@
import { Link } from 'react-router-dom'
import { PuzzlePieceIcon } from '@heroicons/react/24/outline'
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { linkify } from '../lib/glossary'
type Building = { title: string; body: string }
type BuiltinPlugin = { label: string; body: string; hosts: string[]; hooks: string[] }
type Step = { title: string; body: string }
type HookKind = { name: string; behavior: string; use: string }
const BUILDING_BLOCKS: Record<'en' | 'de', Building[]> = {
en: [
{ title: 'Metadata (PluginMeta)', body: 'name, label, description, version, a declared permission surface (db_read, db_write, external_hosts, hooks — for review, not runtime enforcement), and an optional Celery Beat schedule.' },
{ title: 'Router', body: 'A FastAPI APIRouter, mounted under /api/v1 at startup — but only for plugins that are enabled.' },
{ title: 'Tasks', body: 'Celery tasks for anything that talks to an external system. Hooks queue work; they never block the event loop on I/O.' },
{ title: 'Hooks', body: 'Handlers registered against the central event bus (hook_registry) via the @hook decorator — react to events like poll.complete.' },
],
de: [
{ title: 'Metadaten (PluginMeta)', body: 'name, label, description, version, eine deklarierte Permission-Surface (db_read, db_write, external_hosts, hooks — für Review, nicht zur Laufzeit erzwungen) und ein optionaler Celery-Beat-Schedule.' },
{ title: 'Router', body: 'Ein FastAPI APIRouter, beim Start unter /api/v1 gemountet — aber nur für aktivierte Plugins.' },
{ title: 'Tasks', body: 'Celery-Tasks für alles, was mit einem externen System spricht. Hooks reihen Arbeit ein; sie blockieren den Event-Loop nie mit I/O.' },
{ title: 'Hooks', body: 'Handler, die über den @hook-Decorator am zentralen Event-Bus (hook_registry) registriert werden — reagieren auf Events wie poll.complete.' },
],
}
const BUILTIN: Record<'en' | 'de', BuiltinPlugin[]> = {
en: [
{ label: 'Wazuh Security', body: 'Syncs device enrollment with the Wazuh manager and surfaces agent health warnings, vulnerability counts, alerts, and CIS benchmark scores.', hosts: ['wazuh-manager:55000', 'wazuh-indexer:9200'], hooks: ['poll.complete'] },
{ label: 'Graylog Syslog', body: 'Checks whether syslog-capable devices forward logs to Graylog via rsyslog, and can automatically write the forwarding rule.', hosts: [], hooks: [] },
{ label: 'CrowdSec', body: 'Surfaces org-level CrowdSec security health data per device: active decisions, blocked requests, remediation metrics, and top attack scenarios.', hosts: ['admin.api.crowdsec.net:443'], hooks: [] },
{ label: 'apt-cacher-ng', body: "Auto-detects apt-cacher-ng on Docker hosts and configures the APT proxy on devices within the same site so APT traffic is routed through the local cache.", hosts: [], hooks: ['poll.complete'] },
{ label: 'EOL Tracking', body: "Flags devices running end-of-life or soon-to-be-end-of-life firmware/OS versions, checked daily against the endoflife.date API. Reuses the OS version already collected during polling — no extra device I/O.", hosts: ['endoflife.date'], hooks: [] },
],
de: [
{ label: 'Wazuh Security', body: 'Synchronisiert die Geräte-Registrierung mit dem Wazuh-Manager und zeigt Agent-Health-Warnungen, Schwachstellenanzahl, Alerts und CIS-Benchmark-Scores an.', hosts: ['wazuh-manager:55000', 'wazuh-indexer:9200'], hooks: ['poll.complete'] },
{ label: 'Graylog Syslog', body: 'Prüft, ob syslog-fähige Geräte Logs via rsyslog an Graylog weiterleiten, und kann die Weiterleitungsregel automatisch schreiben.', hosts: [], hooks: [] },
{ label: 'CrowdSec', body: 'Zeigt Org-Level-CrowdSec-Sicherheitsdaten pro Gerät: aktive Entscheidungen, geblockte Requests, Remediation-Metriken und Top-Angriffsszenarien.', hosts: ['admin.api.crowdsec.net:443'], hooks: [] },
{ label: 'apt-cacher-ng', body: 'Erkennt apt-cacher-ng automatisch auf Docker-Hosts und konfiguriert den APT-Proxy auf Geräten im selben Standort, sodass APT-Traffic über den lokalen Cache läuft.', hosts: [], hooks: ['poll.complete'] },
{ label: 'EOL-Tracking', body: 'Kennzeichnet Geräte mit End-of-Life- oder bald End-of-Life-Firmware/OS, täglich gegen die endoflife.date-API geprüft. Nutzt die beim Polling bereits erfasste OS-Version — keine zusätzliche Geräte-I/O.', hosts: ['endoflife.date'], hooks: [] },
],
}
const STEPS: Record<'en' | 'de', Step[]> = {
en: [
{ title: 'Declare metadata', body: 'Create netork/plugins/<name>/__init__.py with a PluginMeta — permissions are declarative, for human review, not enforced at runtime.' },
{ title: 'Register', body: 'Implement register(): register the metadata with plugin_registry and import your hooks module so its @hook decorators run.' },
{ title: 'Add hooks', body: 'hooks.py: handlers for the events you care about, e.g. poll.complete.' },
{ title: 'Add a router (optional)', body: 'router.py: a FastAPI APIRouter, returned from a _get_router() callable passed to plugin_registry.register().' },
{ title: 'Add tasks', body: 'tasks.py: Celery tasks for anything that talks to an external system — hooks should queue work, not block.' },
],
de: [
{ title: 'Metadaten deklarieren', body: 'netork/plugins/<name>/__init__.py mit einer PluginMeta anlegen — Permissions sind deklarativ, für menschliches Review, nicht zur Laufzeit erzwungen.' },
{ title: 'Registrieren', body: 'register() implementieren: Metadaten bei plugin_registry registrieren und das hooks-Modul importieren, damit dessen @hook-Decorators laufen.' },
{ title: 'Hooks hinzufügen', body: 'hooks.py: Handler für die Events, die dich interessieren, z. B. poll.complete.' },
{ title: 'Router hinzufügen (optional)', body: 'router.py: ein FastAPI APIRouter, zurückgegeben von einem _get_router()-Callable, das an plugin_registry.register() übergeben wird.' },
{ title: 'Tasks hinzufügen', body: 'tasks.py: Celery-Tasks für alles, was mit einem externen System spricht — Hooks sollten Arbeit einreihen, nicht blockieren.' },
],
}
const HOOK_KINDS: Record<'en' | 'de', HookKind[]> = {
en: [
{ name: 'fire()', behavior: 'Fire-and-forget. All handlers run concurrently; exceptions are logged, never raised. 30 s timeout per handler.', use: 'Side effects after an event, e.g. "queue a Wazuh sync after a poll completes".' },
{ name: 'call()', behavior: 'Blocking. Handlers run sequentially by priority; exceptions propagate to the caller. 10 s timeout per handler.', use: 'When the caller needs to know a handler failed.' },
{ name: 'transform()', behavior: 'Pipeline. Handlers run sequentially, each receiving and returning value. 10 s timeout per handler.', use: 'Letting plugins enrich or modify a value in place, e.g. annotate a payload before it\'s persisted.' },
],
de: [
{ name: 'fire()', behavior: 'Fire-and-forget. Alle Handler laufen gleichzeitig; Exceptions werden geloggt, nie geworfen. 30 s Timeout pro Handler.', use: 'Seiteneffekte nach einem Event, z. B. „Wazuh-Sync nach abgeschlossenem Poll einreihen".' },
{ name: 'call()', behavior: 'Blockierend. Handler laufen sequenziell nach Priorität; Exceptions propagieren zum Aufrufer. 10 s Timeout pro Handler.', use: 'Wenn der Aufrufer wissen muss, dass ein Handler fehlgeschlagen ist.' },
{ name: 'transform()', behavior: 'Pipeline. Handler laufen sequenziell, jeder erhält und liefert value zurück. 10 s Timeout pro Handler.', use: 'Plugins lassen einen Wert anreichern oder verändern, z. B. ein Payload vor dem Speichern annotieren.' },
],
}
const REGISTRY_PARAGRAPHS: Record<'en' | 'de', string[]> = {
en: [
'Enabled state lives in the database as a Setting row: plugin.<name>.enabled = "true" | "false". The registry exposes enable() and disable(), gated behind RBAC like every other orchestration action.',
"Routers are mounted once at FastAPI startup for every enabled plugin — FastAPI doesn't support unmounting routers at runtime, so disabling a plugin's API surface takes effect after the next deploy.",
"Hook handlers are registered as soon as a plugin's hooks module is imported. Whether a handler is a no-op until configured is up to the handler itself — the bundled plugins check their own Settings before acting.",
],
de: [
'Der Aktivierungsstatus liegt in der Datenbank als Setting-Zeile: plugin.<name>.enabled = "true" | "false". Die Registry stellt enable() und disable() bereit, abgesichert durch RBAC wie jede andere Orchestrierungsaktion.',
'Router werden beim FastAPI-Start einmal für jedes aktivierte Plugin gemountet — FastAPI unterstützt kein Unmounten zur Laufzeit, daher wirkt sich das Deaktivieren der API-Oberfläche eines Plugins erst nach dem nächsten Deploy aus.',
'Hook-Handler werden registriert, sobald das hooks-Modul eines Plugins importiert wird. Ob ein Handler bis zur Konfiguration ein No-op bleibt, entscheidet der Handler selbst — die mitgelieferten Plugins prüfen vor dem Handeln ihre eigenen Settings.',
],
}
const META_CODE = `# netork/plugins/myplugin/__init__.py
from netork.plugins import PluginMeta, PluginPermissions
from netork.plugins.registry import plugin_registry
_META = PluginMeta(
name="myplugin",
label="My Plugin",
description="What this plugin does.",
permissions=PluginPermissions(
db_read=["Device"],
external_hosts=["myservice:443"],
hooks=["poll.complete"],
),
)
def register() -> None:
plugin_registry.register(_META, get_router=_get_router)
from netork.plugins.myplugin import hooks as _hooks # noqa: F401
def _get_router():
from netork.plugins.myplugin.router import router
return router`
import { CodeBlock, H2, PageHeader, RuleList, Section, Split } from '../components/ui'
const HOOK_CODE = `# netork/plugins/myplugin/hooks.py
from netork.plugins.hooks import hook
@hook("poll.complete", priority=100)
async def _on_poll_complete(device_id: str, session_factory, **kwargs) -> None:
async def on_poll_complete(device_id: str, **kwargs) -> None:
# queue a Celery task — never block the event loop here
from netork.plugins.myplugin.tasks import sync_device
sync_device.apply_async(args=[device_id], queue="default")`
function CodeBlock({ code }: { code: string }) {
return (
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6 font-mono text-sm text-slate-300 overflow-x-auto">
<code>{code}</code>
</pre>
)
}
function HostTag({ value }: { value: string }) {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono text-slate-400 border border-slate-700 bg-slate-950">
{value}
</span>
)
}
export default function Plugins() {
const { lang, t } = useLang()
const blocks = BUILDING_BLOCKS[lang]
const builtin = BUILTIN[lang]
const steps = STEPS[lang]
const hookKinds = HOOK_KINDS[lang]
const registryParagraphs = REGISTRY_PARAGRAPHS[lang]
const { t } = useLang()
const p = t.plugins
usePageTitle(t.meta.plugins)
return (
<div className="py-16 md:py-24">
<div className="max-w-4xl mx-auto px-6">
<>
<PageHeader eyebrow={p.eyebrow} title={p.title} lead={linkify(p.lead)} />
<div className="mb-16">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.plugins.heading}</h1>
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.plugins.sub)}</p>
</div>
<Section className="pt-0 md:pt-0">
<Split aside={<H2>{p.builtinTitle}</H2>}>
<RuleList
items={p.builtin.map((b) => ({
term: b.title,
aside: b.hosts ? <span className="font-mono text-xs font-normal text-ink-muted">{b.hosts}</span> : undefined,
body: linkify(b.body),
}))}
/>
</Split>
</Section>
<div className="mb-20">
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
{t.plugins.whatHeading}
</h2>
<div className="grid sm:grid-cols-2 gap-6">
{blocks.map((b) => (
<div key={b.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">{b.title}</p>
<p className="text-sm text-slate-500 leading-relaxed">{linkify(b.body)}</p>
<Section band>
<Split aside={<H2>{p.buildTitle}</H2>}>
<RuleList items={p.build.map((b) => ({ term: b.title, body: linkify(b.body) }))} />
</Split>
<div className="mt-14 grid gap-8 lg:grid-cols-12">
<div className="lg:col-span-5">
<p className="text-h3">{p.codeTitle}</p>
<p className="mt-2 text-ink-soft">{linkify(p.enable)}</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">
{t.plugins.builtinHeading}
</h2>
<div className="grid sm:grid-cols-2 gap-6">
{builtin.map((p) => (
<div key={p.label} 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.label}</p>
<p className="text-sm text-slate-500 leading-relaxed mb-3">{linkify(p.body)}</p>
{(p.hosts.length > 0 || p.hooks.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{p.hosts.map((h) => <HostTag key={h} value={h} />)}
{p.hooks.map((h) => <HostTag key={h} value={`hook: ${h}`} />)}
</div>
)}
</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">
{t.plugins.writingHeading}
</h2>
<ol className="space-y-4 mb-8">
{steps.map((s, i) => (
<li key={s.title} className="flex gap-4">
<span className="shrink-0 h-6 w-6 rounded-full bg-sky-600/10 text-sky-400 text-xs font-semibold flex items-center justify-center mt-0.5">
{i + 1}
</span>
<div>
<p className="text-sm font-medium text-slate-200">{s.title}</p>
<p className="text-sm text-slate-500 leading-relaxed">{linkify(s.body)}</p>
</div>
</li>
))}
</ol>
<div className="space-y-4">
<CodeBlock code={META_CODE} />
<div className="min-w-0 lg:col-span-7">
<CodeBlock code={HOOK_CODE} />
</div>
</div>
<div className="mb-20">
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
{t.plugins.hookBusHeading}
</h2>
<div className="space-y-4">
{hookKinds.map((h) => (
<div key={h.name} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
<p className="text-sm font-mono font-semibold text-sky-400 mb-1.5">{h.name}</p>
<p className="text-sm text-slate-400 leading-relaxed mb-2">{linkify(h.behavior)}</p>
<p className="text-sm text-slate-500 leading-relaxed">{linkify(h.use)}</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">
{t.plugins.registryHeading}
</h2>
<div className="space-y-4">
{registryParagraphs.map((p) => (
<p key={p} className="text-sm text-slate-400 leading-relaxed">{linkify(p)}</p>
))}
</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.plugins.cta.heading}</h2>
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.plugins.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.plugins.cta.button}
</Link>
</div>
</div>
</div>
</Section>
</>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { useLang } from '../context/LangContext'
import { usePageTitle } from '../lib/usePageTitle'
import { PLANS, PORTAL_URL, type Plan } from '../data/plans'
import { ButtonLink, Container, H2, PageHeader, RuleList, Section, TextLink } from '../components/ui'
function Check({ on }: { on: boolean }) {
return on ? (
<span className="text-sync" aria-label="yes">✓</span>
) : (
<span className="text-ink-faint" aria-label="no">—</span>
)
}
export default function Pricing() {
const { t } = useLang()
const p = t.pricing
usePageTitle(t.meta.pricing)
const rows: { label: string; value: (plan: Plan) => React.ReactNode }[] = [
{ label: p.rows.vulnData, value: (plan) => <Check on={plan.vulnData} /> },
{ label: p.rows.evidence, value: (plan) => <Check on={plan.evidence} /> },
{ label: p.rows.cwe, value: (plan) => <Check on={plan.cwe} /> },
{ label: p.rows.history, value: (plan) => p.history[plan.historyDays] },
{ label: p.rows.channel, value: (plan) => (plan.edge ? p.channel.edge : p.channel.stable) },
]
return (
<>
<PageHeader title={p.title} lead={p.lead} />
<Container>
{/* The free core, as a plain row above the licence plans. */}
<div className="grid gap-2 border-t border-line-strong py-6 sm:grid-cols-[12rem_1fr] sm:gap-8">
<p className="text-h3">
{p.core.name} <span className="ml-2 font-normal text-sync">{p.core.price}</span>
</p>
<p className="text-ink-soft">{p.core.body}</p>
</div>
<h2 className="mt-12 text-h3 text-ink-muted">{p.licence}</h2>
{/* md and up: one comparison table. */}
<table className="mt-4 hidden w-full border-collapse text-left md:table">
<thead>
<tr className="border-b border-line-strong align-bottom">
<th className="w-1/3 py-4" />
{PLANS.map((plan) => (
<th key={plan.id} className="px-4 py-4">
<p className="text-h3">{plan.name}</p>
<p className="mt-1 text-sm font-normal text-ink-muted">{p.onRequest}</p>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.label} className="border-b border-line">
<th scope="row" className="py-4 pr-4 font-normal text-ink-soft">
{row.label}
</th>
{PLANS.map((plan) => (
<td key={plan.id} className="px-4 py-4 text-ink">
{row.value(plan)}
</td>
))}
</tr>
))}
<tr>
<td />
{PLANS.map((plan) => (
<td key={plan.id} className="px-4 pt-6">
<ButtonLink to={PORTAL_URL}>
{p.buy}
</ButtonLink>
</td>
))}
</tr>
</tbody>
</table>
{/* Below md: one block per plan. */}
<div className="mt-4 md:hidden">
{PLANS.map((plan) => (
<div key={plan.id} className="border-t border-line-strong py-6">
<p className="text-h3">
{plan.name} <span className="ml-2 text-sm font-normal text-ink-muted">{p.onRequest}</span>
</p>
<dl className="mt-4 space-y-2 text-sm">
{rows.map((row) => (
<div key={row.label} className="flex justify-between gap-4">
<dt className="text-ink-soft">{row.label}</dt>
<dd className="text-ink">{row.value(plan)}</dd>
</div>
))}
</dl>
<ButtonLink to={PORTAL_URL} className="mt-5 w-full">
{p.buy}
</ButtonLink>
</div>
))}
</div>
<p className="mt-8 max-w-3xl text-sm text-ink-muted">{p.allPlans}</p>
</Container>
<Section>
<H2>{p.faqTitle}</H2>
<div className="mt-10">
<RuleList columns={2} items={p.faq.map((f) => ({ term: f.title, body: f.body }))} />
</div>
<p className="mt-10 text-ink-soft">
{p.install} <TextLink to="/docs/getting-started">{p.installLink}</TextLink>
</p>
</Section>
</>
)
}
-188
View File
@@ -1,188 +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: 'Planned',
items: [
{
title: 'CVE tracking per device',
detail: 'Cross-reference installed packages and OS versions against NVD / OSV. Surfaces "this device has 3 unpatched CVEs (CVSS ≥ 7)" without leaving netOrk.',
nis2: true,
},
{
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 — rework',
detail: 'Push reusable firewall rule templates to OPNsense and OpenWRT devices. The existing implementation is being re-scoped from scratch — profile types, rule sets, and the push mechanism are all under review before further work lands.',
},
],
},
{
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: 'Geplant',
items: [
{
title: 'CVE-Tracking pro Gerät',
detail: 'CVE-Abgleich mit installierten Paketen und OS-Versionen über NVD / OSV. Zeigt „Dieses Gerät hat 3 ungepatchte CVEs (CVSS ≥ 7)" direkt in netOrk an.',
nis2: true,
},
{
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 — Überarbeitung',
detail: 'Wiederverwendbare Firewall-Regel-Templates auf OPNsense- und OpenWRT-Geräte pushen. Die bestehende Implementierung wird von Grund auf neu bewertet — Profiltypen, Regelsätze und der Push-Mechanismus stehen vor der Weiterentwicklung 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.split('NIS2')[0]}
<Nis2Badge />
{t.roadmap.sub.split('NIS2')[1]}
</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>
)
}
+33 -1
View File
@@ -1,8 +1,40 @@
import defaultTheme from 'tailwindcss/defaultTheme'
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
extend: {
// Light pages, dark product. See docs/DESIGN.md before adding a colour.
colors: {
paper: { DEFAULT: '#FAFAF9', 2: '#F3F3F0' },
line: { DEFAULT: '#E6E6E3', strong: '#D4D4D0' },
ink: { DEFAULT: '#0E1116', soft: '#3D434D', muted: '#6B717B', faint: '#9AA0A8' },
// The netOrk UI's own background, so screenshot edges disappear into the stage.
night: { DEFAULT: '#020617', line: '#1E293B' },
accent: { DEFAULT: '#0369A1', hover: '#075985', soft: '#E0F2FE' },
drift: { DEFAULT: '#B45309', soft: '#FEF3C7' },
sync: { DEFAULT: '#15803D', soft: '#DCFCE7' },
},
fontFamily: {
sans: ['"Inter Variable"', ...defaultTheme.fontFamily.sans],
},
fontSize: {
display: ['clamp(2.5rem, 1.5rem + 4vw, 4.75rem)', { lineHeight: '1.02', letterSpacing: '-0.04em', fontWeight: '600' }],
h1: ['clamp(2rem, 1.5rem + 2.2vw, 3.25rem)', { lineHeight: '1.08', letterSpacing: '-0.03em', fontWeight: '600' }],
h2: ['clamp(1.625rem, 1.3rem + 1.4vw, 2.5rem)', { lineHeight: '1.12', letterSpacing: '-0.025em', fontWeight: '600' }],
h3: ['1.125rem', { lineHeight: '1.45', letterSpacing: '-0.01em', fontWeight: '600' }],
lead: ['clamp(1.0625rem, 1rem + 0.35vw, 1.25rem)', { lineHeight: '1.6' }],
eyebrow: ['0.75rem', { lineHeight: '1rem', letterSpacing: '0.08em' }],
},
maxWidth: {
page: '72rem',
measure: '38rem',
},
boxShadow: {
stage: '0 30px 60px -20px rgba(2, 6, 23, 0.35)',
},
},
},
plugins: [],
}