Redesign: control instead of drift #3

Merged
christianmanivong merged 2 commits from feature/redesign into main 2026-09-30 05:44:34 +00:00
53 changed files with 1957 additions and 3478 deletions
+3
View File
@@ -12,3 +12,6 @@ deploy.env
# Screenshot tooling: bytecode, and database dumps that hold production data
__pycache__/
*.dump
# Output of scripts/check/site.py
.check/
+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.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

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: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 124 KiB

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