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>
This commit is contained in:
Christian Manivong
2026-09-29 23:24:49 +02:00
co-authored by Claude Opus 5.5
parent 369f66afdc
commit f70fec496a
35 changed files with 1698 additions and 2716 deletions
+24 -13
View File
@@ -10,9 +10,12 @@ 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.
@@ -74,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`.
---
@@ -108,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 -285
View File
@@ -1,304 +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/devices.webp" alt="Device inventory" className="w-full" />
</div>
```
**Only real screenshots of the running application.** No JSX mockups or
drawn imitations of the UI. They come from an anonymized demo copy of a real
installation and are taken with `scripts/screenshots/capture.py` (see
`scripts/demo/README.md`), published as WebP in `public/screenshots/`.
`Screenshot` in `src/pages/Home.tsx` is the frame.
Optionally add a browser chrome header above the image:
```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 / devices</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 -437
View File
@@ -1,442 +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. Six alternating image + text rows, each a
real screenshot from `scripts/screenshots/shots.py`. Copy lives in
`home.screenshot1`–`screenshot6` in `src/i18n/translations.ts`.
| Row | Heading | Screenshot |
|---|---|---|
| 1 | Device detail at a glance | `device-detail` — an access point, Networking → Interfaces |
| 2 | Intent-based VLAN and SSID management | `vlans` — VLAN list by site |
| 3 | A security assessment for every device | `device-security` — a server, Security → Assessment |
| 4 | One triage queue, decisions that hold | `vulnerabilities` — the triage queue |
| 5 | Dashboards you actually build | `dashboard` — the home dashboard |
| 6 | Service checks every minute | `service-checks` — Network → Service Checks |
Text sits left on odd rows and right on even rows; on mobile the text always
comes first.
---
### Section 5b — NIS2
**Purpose:** Hook for organizations evaluating netOrk in a NIS2 context.
**Layout:** Left column — label + Art. 21 mapping list. Right column — screenshot of the audit log (`audit-log`), the evidence the list refers to.
**Label (eyebrow):** `NIS2 · Art. 21` (sky-500, uppercase, tracking-widest)
**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.
+17
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,
+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.

Before

Width:  |  Height:  |  Size: 79 KiB

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.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 124 KiB

+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 />
+64 -40
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>
<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>
)
}
+5 -5
View File
@@ -45,17 +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 shadow-2xl hidden group-hover:block group-focus-visible:block
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>
+83 -132
View File
@@ -3,32 +3,27 @@ import { Link, useLocation } from 'react-router-dom'
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>
)
}
const mobileItemCls =
'block rounded-lg px-3 py-2 text-sm text-slate-300 hover:text-slate-100 hover:bg-slate-800 transition-colors'
const mobileHeadingCls = 'px-3 pt-4 pb-1 text-xs font-semibold uppercase tracking-widest text-slate-500'
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
}) {
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)
@@ -42,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>
)}
@@ -58,128 +52,86 @@ 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)
useEffect(() => {
setMenuOpen(false)
}, [location.pathname])
}, [pathname])
const linkCls = (path: string) =>
`text-sm transition-colors ${
location.pathname === path
? 'text-slate-100'
: 'text-slate-400 hover:text-slate-100'
}`
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-4 sm: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>
<div className="hidden lg:flex items-center gap-8">
<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}
</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}
</Link>
<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>
{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="hidden sm: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"
>
{t.nav.getStarted}
</Link>
<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="Menu"
className="lg:hidden -mr-1 p-1 text-slate-400 hover:text-slate-100 transition-colors"
aria-label={t.nav.menu}
className="-mr-1 p-1 text-ink-muted transition-colors hover:text-ink lg:hidden"
>
{menuOpen ? <XMarkIcon className="h-6 w-6" /> : <Bars3Icon className="h-6 w-6" />}
</button>
</div>
</div>
</Container>
{menuOpen && (
<div className="lg:hidden border-t border-slate-800 bg-slate-900 max-h-[calc(100vh-3.5rem)] overflow-y-auto">
<div className="px-4 py-3">
<Link to="/features" className={mobileItemCls}>{t.nav.features}</Link>
<Link to="/drivers" className={mobileItemCls}>{t.nav.drivers}</Link>
<Link to="/plugins" className={mobileItemCls}>{t.nav.plugins}</Link>
<Link to="/roadmap" className={mobileItemCls}>{t.nav.roadmap}</Link>
<p className={mobileHeadingCls}>{t.nav.docs}</p>
<Link to="/docs/getting-started" className={mobileItemCls}>{t.nav.docsItems.gettingStarted}</Link>
<Link to="/docs/architecture" className={mobileItemCls}>{t.nav.docsItems.architecture}</Link>
<Link to="/nis2" className={mobileItemCls}>{t.nav.docsItems.nis2}</Link>
<Link to="/glossary" className={mobileItemCls}>{t.nav.docsItems.glossary}</Link>
<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>
<Link to="/for/it-department" className={mobileItemCls}>{t.nav.forItems.itDepartment}</Link>
<Link to="/for/it-support" className={mobileItemCls}>{t.nav.forItems.itSupport}</Link>
<Link to="/for/msp" className={mobileItemCls}>{t.nav.forItems.msp}</Link>
<Link
to="/docs/getting-started"
className="sm:hidden mt-4 flex justify-center px-4 py-2 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
>
{t.nav.getStarted}
</Link>
</div>
{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>
)}
</nav>
@@ -189,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">
<button
onClick={() => setLang('de')}
className={`transition-colors ${lang === 'de' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
>
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
</button>
{(['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(l)}
aria-pressed={lang === l}
className={`uppercase transition-colors ${lang === l ? 'text-ink' : 'text-ink-faint hover:text-ink-soft'}`}
>
{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
}
}
+510 -966
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'
+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-3xl sm: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>
)
}
+42 -38
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
<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]}
</a>
</li>
))}
</ul>
</nav>
return (
<div key={category}>
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
{CATEGORY_LABELS[category][lang]}
</h2>
<dl className="space-y-6">
<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>
)
})}
</section>
))}
</div>
</div>
</div>
</div>
</Container>
</>
)
}
+94 -253
View File
@@ -1,271 +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 = [
'Fritz!Box', 'HPE OfficeConnect', 'HPE ProCurve / Aruba', 'Linux', 'Netgear',
'OpenMediaVault', 'OpenWrt', 'OPNsense', 'Proxmox VE', 'QNAP QTS', 'Sonos',
'TP-Link JetStream', 'Yealink', 'Zyxel VMG',
]
function Screenshot({ src, label, alt }: { src: string; label: string; alt: string }) {
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>
<img src={src} alt={alt} loading="lazy" className="block w-full" width={1600} height={1000} />
</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>
</Section>
<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>
</div>
<div className="max-w-7xl mx-auto px-6 mt-16">
<Screenshot src="/screenshots/devices.webp" label="netork / devices" alt={h.shotAlt.devices} />
</div>
</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>
</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>
<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>
<Screenshot src="/screenshots/device-detail.webp" label="netork / devices / ap-floor1" alt={h.shotAlt.deviceDetail} />
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="md:order-2">
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot2.heading}</h2>
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot2.body)}</p>
</div>
<Screenshot src="/screenshots/vlans.webp" label="netork / vlans" alt={h.shotAlt.vlans} />
</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>
<Screenshot src="/screenshots/device-security.webp" label="netork / devices / proxy-01 / security" alt={h.shotAlt.deviceSecurity} />
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="md:order-2">
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot4.heading}</h2>
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot4.body)}</p>
</div>
<Screenshot src="/screenshots/vulnerabilities.webp" label="netork / vulnerabilities" alt={h.shotAlt.vulnerabilities} />
</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>
<Screenshot src="/screenshots/dashboard.webp" label="netork / dashboard" alt={h.shotAlt.dashboard} />
</div>
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="md:order-2">
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot6.heading}</h2>
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot6.body)}</p>
</div>
<Screenshot src="/screenshots/service-checks.webp" label="netork / monitoring / checks" alt={h.shotAlt.serviceChecks} />
</div>
</div>
</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>
<Screenshot src="/screenshots/audit-log.webp" label="netork / audit-log" alt={h.shotAlt.auditLog} />
</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>
)
}
+44 -220
View File
@@ -1,233 +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: 'Installed software and container images on every device are matched against known vulnerabilities, rated by what each flaw means on that device. A triage queue records a decision per vulnerability — not applicable, accepted until, deferred until, fixed — each with a reason, a date and who decided, written to the audit log. Deferrals come back by themselves, and a daily reassessment verifies fixes. Vulnerability data requires a netOrk licence.' },
{ 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. Terminal sessions to devices log in with each user\'s own SSH key, never a shared account, and opened and refused sessions are recorded.' },
{ 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: 'Installierte Software und Container-Images jedes Geräts werden gegen bekannte Schwachstellen abgeglichen und danach bewertet, was die Lücke auf genau diesem Gerät bedeutet. Eine Triage-Queue hält pro Schwachstelle eine Entscheidung fest — nicht zutreffend, akzeptiert bis, zurückgestellt bis, behoben — jeweils mit Begründung, Datum und Entscheider, im Audit-Log protokolliert. Zurückgestelltes kommt von selbst zurück, eine tägliche Neubewertung verifiziert Behebungen. Die Schwachstellendaten setzen eine netOrk-Lizenz voraus.' },
{ 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. Terminal-Sitzungen zu Geräten melden sich mit dem eigenen SSH-Schlüssel des Benutzers an, nie mit einem geteilten Konto; geöffnete und verweigerte Sitzungen werden protokolliert.' },
{ 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: '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: '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>
<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>
))}
</dl>
</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.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>
</div>
<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>
))}
</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>
</ul>
</Split>
<div className="mt-14">
<Shot name="audit-log" alt={t.shots.auditLog} caption={n.auditCaption} />
</div>
</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)}
</li>
))}
</ul>
</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.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>}
/>
</>
)
}
+31 -229
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" />
<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>
<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>
<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>
<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>
</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>
</>
)
}
+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: [],
}