Compare commits
10
Commits
451a98c14a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
992df0e6b1 | ||
|
|
f70fec496a | ||
|
|
369f66afdc | ||
|
|
d585f9272f | ||
|
|
8a1cf7b2b6 | ||
|
|
011f816fc9 | ||
|
|
38834100d1 | ||
|
|
4cba6e156c | ||
|
|
04c5c00280 | ||
|
|
8aa158aa3b |
@@ -8,3 +8,10 @@ memory/
|
||||
|
||||
# Deploy target + registry token
|
||||
deploy.env
|
||||
|
||||
# Screenshot tooling: bytecode, and database dumps that hold production data
|
||||
__pycache__/
|
||||
*.dump
|
||||
|
||||
# Output of scripts/check/site.py
|
||||
.check/
|
||||
|
||||
@@ -10,14 +10,38 @@ get started.
|
||||
## Project Purpose
|
||||
|
||||
Potential users land here and need to answer three questions in under 10 seconds:
|
||||
1. What is this?
|
||||
2. Is it for me?
|
||||
3. How do I try it?
|
||||
1. What is this? — Control instead of drift: define the desired state once,
|
||||
netOrk finds every deviation and puts it back.
|
||||
2. Is it for me? — IT departments in small and mid-sized companies with mixed
|
||||
hardware (service providers and IT support are secondary).
|
||||
3. What does it cost? — netOrk is free; a licence adds vulnerability data and
|
||||
updates (`/pricing`).
|
||||
|
||||
Everything on the site should serve those three questions.
|
||||
|
||||
---
|
||||
|
||||
## Infrastruktur ändert man woanders — und vorher
|
||||
|
||||
Diese Anwendung läuft auf einer Infrastruktur, die sie sich mit anderen Projekten teilt:
|
||||
Netz, Datenbankcluster, öffentlicher Eingang und Backup gehören keinem Projekt allein.
|
||||
Dokumentiert ist sie in **`git.netork.io/christianmanivong/infrastructure`**, und dort
|
||||
steht in `CLAUDE.md` auch die verbindliche Regel.
|
||||
|
||||
**Kurz: erst dort dokumentieren, ausdrücklich genehmigen lassen, dann ändern.** Nicht
|
||||
umgekehrt, und „ja mach mal" zu einer früheren Frage deckt die nächste Änderung nicht mit
|
||||
ab.
|
||||
|
||||
Betroffen ist alles, was über dieses Repo hinausreicht — Hosts, Netze, Firewall-Regeln,
|
||||
der Patroni-Cluster samt `pg_hba` und DCS-Parametern, pgBackRest, BunkerWeb-Hosts, DNS,
|
||||
CI-Runner, alles, was eine Anwendung auf den geteilten Datenbankcluster umzieht.
|
||||
**Nicht** betroffen: Anwendungscode, Abhängigkeiten und Migrationen innerhalb der eigenen
|
||||
Datenbank.
|
||||
|
||||
Der Grund für die Reihenfolge ist nicht Bürokratie. Die meisten Zwischenfälle dort waren
|
||||
nicht falsche Werte, sondern richtige Werte in der falschen Reihenfolge — und das fällt
|
||||
beim Aufschreiben auf, nicht beim Tippen. Im Zweifel dorthin.
|
||||
|
||||
## Content & Design Source of Truth
|
||||
|
||||
All product content (features, copy, page structure) is in `docs/`:
|
||||
@@ -53,13 +77,19 @@ exactly as netOrk's `ui/src/components/ui.tsx` does.
|
||||
|
||||
## Design Rules (summary — full detail in docs/DESIGN.md)
|
||||
|
||||
- **Dark theme only.** Background `bg-slate-950`. Cards `bg-slate-900`.
|
||||
- **Accent color:** `sky-500` / `sky-600` for CTAs, links, highlights.
|
||||
- **No light mode toggle.** Ever.
|
||||
- Font stack: system default (Tailwind sans). No Google Fonts.
|
||||
- All interactive elements use `transition-colors` — no layout shifts.
|
||||
- Screenshots/mockups of the actual app use a `border border-slate-700 rounded-xl
|
||||
overflow-hidden` wrapper to frame them against the dark background.
|
||||
- **Light pages, dark product.** Pages are `paper` with `ink`; the only dark
|
||||
surfaces are real netOrk screenshots and code, on the `night` stage.
|
||||
- One accent, `accent` (sky-700), for links, eyebrows and focus. Buttons are ink.
|
||||
- Font: Inter Variable, self-hosted via `@fontsource-variable/inter`. No Google
|
||||
Fonts, no request to any other origin.
|
||||
- Layout from `src/components/ui.tsx`: split sections, ruled lists, numbered
|
||||
steps, bands. No card grids, no icon tiles, no centred text blocks.
|
||||
- Motion: `transition-colors` only — no layout shifts.
|
||||
- **Screenshots are real.** Taken from the anonymised demo copy with
|
||||
`scripts/screenshots/capture.py`, cropped to what the text talks about, at most
|
||||
four on the site. No mockups, no edited data, no clicks that fake a state.
|
||||
- **Less text.** Word budgets per page are in docs/PAGES.md and checked by
|
||||
`scripts/check/site.py`.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +117,9 @@ netork-website/
|
||||
|
||||
## Tone of Voice
|
||||
|
||||
- Direct and technical — audience is engineers, not executives.
|
||||
- Direct and technical — audience is the admins of an IT department, not executives.
|
||||
- One message: control instead of drift. Everything else supports it.
|
||||
- No marketing fluff ("revolutionize", "empower", "seamless").
|
||||
- Show, don't tell — a screenshot or code block beats three sentences of prose.
|
||||
- German is fine for internal docs; the website copy is in **English**.
|
||||
- Show, don't tell — a real screenshot beats three sentences of prose.
|
||||
- Website copy exists in **English and German** (German addresses readers with
|
||||
"ihr"); the language follows the browser until the visitor chooses.
|
||||
|
||||
+59
-279
@@ -1,298 +1,78 @@
|
||||
# netOrk — Visual Identity & Design System
|
||||
# netOrk website — design system
|
||||
|
||||
This document defines the visual identity of netOrk and must be followed
|
||||
exactly when building the website. The goal is zero visual discontinuity
|
||||
between the product UI and the marketing site.
|
||||
**Light pages, dark product.** The site is calm paper with ink; the only dark
|
||||
surfaces are real screenshots of netOrk and code, set on a "stage". References:
|
||||
Linear/Vercel for precision, Tailscale/Netbird for friendliness.
|
||||
|
||||
---
|
||||
Tokens live in `tailwind.config.js`, building blocks in `src/components/ui.tsx`.
|
||||
If a page needs something that is not there, it probably needs less instead.
|
||||
|
||||
## Logo Assets
|
||||
## Colour
|
||||
|
||||
The logo file is in `public/` — use it directly, do not recreate.
|
||||
|
||||
| File | Format | Size | Use |
|
||||
|---|---|---|---|
|
||||
| `public/logo.png` | PNG | 1024×1024, RGBA | Nav logo, OG image, hero, press kit, favicon fallback |
|
||||
|
||||
### Usage in `<img>` (nav, hero)
|
||||
|
||||
```tsx
|
||||
<img src="/logo.png" alt="netOrk" className="h-8 w-8" />
|
||||
```
|
||||
|
||||
For the nav, pair it with the wordmark:
|
||||
|
||||
```tsx
|
||||
<a href="/" className="flex items-center gap-2.5">
|
||||
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
|
||||
<span className="font-semibold text-slate-100 tracking-tight">
|
||||
net<span className="text-sky-400">Ork</span>
|
||||
</span>
|
||||
</a>
|
||||
```
|
||||
|
||||
Use `net<span class="text-sky-400">Ork</span>` consistently — the `Ork` part
|
||||
in sky-400 ties the wordmark to the accent color.
|
||||
|
||||
### `<head>` references
|
||||
|
||||
```html
|
||||
<link rel="icon" href="/logo.png" />
|
||||
<meta property="og:image" content="/logo.png" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
|
||||
**The website looks like a dark-mode dev tool, not a SaaS landing page.**
|
||||
No gradients, no floating orbs, no animated hero blobs. The aesthetic is
|
||||
deliberate, minimal, and technical — consistent with the product itself.
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
All colors are Tailwind CSS v3 classes. Do not use hex values directly —
|
||||
always use Tailwind class names to stay consistent.
|
||||
|
||||
### Backgrounds
|
||||
|
||||
| Layer | Class | Usage |
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| Page / outermost | `bg-slate-950` | Body, full-bleed sections |
|
||||
| Card / panel | `bg-slate-900` | Content cards, code blocks, feature boxes |
|
||||
| Elevated element | `bg-slate-800` | Hover states, dropdowns, table rows on hover |
|
||||
| Border | `border-slate-700` | Between sections, card outlines |
|
||||
| Subtle border | `border-slate-800` | Inside cards, dividers |
|
||||
| `paper` / `paper-2` | `#FAFAF9` / `#F3F3F0` | page / band |
|
||||
| `line` / `line-strong` | `#E6E6E3` / `#D4D4D0` | hairlines |
|
||||
| `ink` / `ink-soft` / `ink-muted` / `ink-faint` | `#0E1116` … `#9AA0A8` | headings / body / secondary / quiet |
|
||||
| `night` | `#020617` | screenshot and code stage — the netOrk UI's own background |
|
||||
| `accent` (`hover`, `soft`) | `#0369A1` | links, eyebrows, numbers, focus ring (5.7:1 on paper) |
|
||||
| `drift` / `sync` | `#B45309` / `#15803D` | status: deviates / matches; never the only signal |
|
||||
|
||||
### Text
|
||||
No gradients, no glow, no second accent. Buttons are ink, not accent.
|
||||
|
||||
| Role | Class |
|
||||
## Type
|
||||
|
||||
Inter Variable, self-hosted through `@fontsource-variable/inter` (bundled by
|
||||
Vite; the site makes no request to anyone else). Scale:
|
||||
|
||||
| Class | Use |
|
||||
|---|---|
|
||||
| Primary | `text-slate-100` |
|
||||
| Secondary / muted | `text-slate-400` |
|
||||
| Tertiary / placeholder | `text-slate-500` |
|
||||
| Accent (interactive) | `text-sky-400` |
|
||||
| Danger | `text-red-400` |
|
||||
| `text-display` | the home headline only |
|
||||
| `text-h1` | one per page, in `PageHeader` |
|
||||
| `text-h2` | section headings |
|
||||
| `text-h3` | row terms, plan names |
|
||||
| `text-lead` | the paragraph under a heading |
|
||||
| `text-eyebrow` + `uppercase text-accent` | the small line above a heading |
|
||||
|
||||
### Accent (Interactive / CTA)
|
||||
Everything is left-aligned. Headings balance and hyphenate (`<html lang>` is
|
||||
set per language). Text columns stay within `max-w-measure` (38rem).
|
||||
|
||||
| State | Class |
|
||||
|---|---|
|
||||
| Default button | `bg-sky-600 text-white` |
|
||||
| Hover | `hover:bg-sky-500` |
|
||||
| Link / inline | `text-sky-400 hover:text-sky-300` |
|
||||
| Active indicator | `text-sky-400` |
|
||||
| Focus ring | `focus:ring-sky-500` |
|
||||
## Layout
|
||||
|
||||
### Semantic Colors
|
||||
- Container `max-w-page` (72rem), `px-5 sm:px-8`. Sections `py-20 md:py-28`.
|
||||
- **Split**: heading on the left five columns, content on the right. The
|
||||
default section.
|
||||
- **RuleList**: rows divided by hairlines, term and body; one or two columns.
|
||||
This replaces every card grid.
|
||||
- **Steps**: numbered rows (`01`, `02`, `03`) in mono accent.
|
||||
- **Band**: `bg-paper-2` with hairlines, for the hardware strip and the closing
|
||||
call to action (`CtaBand`).
|
||||
- **Stage**: `bg-night`, `rounded-2xl`, `shadow-stage` — screenshots (`Shot`)
|
||||
and code (`CodeBlock`).
|
||||
|
||||
| Meaning | Color |
|
||||
|---|---|
|
||||
| Success / active | `text-green-400`, `bg-green-500/20` |
|
||||
| Warning / caution | `text-yellow-400`, `bg-yellow-500/20` |
|
||||
| Danger / error | `text-red-400`, `bg-red-500/20` |
|
||||
| Info / neutral | `text-blue-400`, `bg-blue-500/20` |
|
||||
Not used: icon tiles, pills for names, cards, centred text blocks, fake browser
|
||||
windows, emoji. Motion is `transition-colors` only.
|
||||
|
||||
### Status Badge Pattern
|
||||
## Screenshots
|
||||
|
||||
```tsx
|
||||
// Active / online
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
|
||||
active
|
||||
</span>
|
||||
Only real screenshots of netOrk, from the anonymised demo copy
|
||||
(`scripts/demo`), taken by `scripts/screenshots/capture.py` and published as
|
||||
WebP in `public/screenshots/`. Their sizes are written to
|
||||
`src/data/screenshots.json`, which `Shot` reads.
|
||||
|
||||
// Offline
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-red-500/20 text-red-400">
|
||||
offline
|
||||
</span>
|
||||
- At most four different images on the site. Each is cropped to the one thing
|
||||
the text next to it talks about (`clip` or `element` in `shots.py`).
|
||||
- Cropping shows less of a real screen; it never changes what is on it. No
|
||||
edited data, no clicks that fake a state, no mockups.
|
||||
- A crop that does not read on a phone gets a `-narrow` variant (`Shot narrow=`).
|
||||
- Every image has an alt text and a caption that says what is true in it.
|
||||
|
||||
// Warning
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">
|
||||
warning
|
||||
</span>
|
||||
```
|
||||
## Wordmark
|
||||
|
||||
---
|
||||
Text only: `net<span class="text-accent">Ork</span>` in ink. `public/logo.png`
|
||||
is the favicon and OG image; it does not sit well on a light background.
|
||||
|
||||
## Typography
|
||||
## Glossary marks
|
||||
|
||||
Font stack: Tailwind default sans-serif (`font-sans`). **No Google Fonts.**
|
||||
The product uses system fonts; the website must match.
|
||||
|
||||
| Element | Classes |
|
||||
|---|---|
|
||||
| Hero heading | `text-4xl md:text-6xl font-bold text-slate-100 leading-tight` |
|
||||
| Section heading | `text-2xl md:text-3xl font-bold text-slate-100` |
|
||||
| Subsection heading | `text-xl font-semibold text-slate-200` |
|
||||
| Body text | `text-base text-slate-400 leading-relaxed` |
|
||||
| Small / label | `text-sm text-slate-400` |
|
||||
| Tiny / tag | `text-xs font-medium text-slate-500` |
|
||||
| Code / monospace | `font-mono text-sky-400` |
|
||||
| Accent text | `text-sky-400` |
|
||||
|
||||
---
|
||||
|
||||
## Spacing & Layout
|
||||
|
||||
- Max content width: `max-w-7xl mx-auto px-6`
|
||||
- Section padding: `py-24` (desktop), `py-16` (mobile)
|
||||
- Card padding: `p-6`
|
||||
- Gap between grid items: `gap-6` or `gap-8`
|
||||
- All layouts are mobile-first; use `md:` and `lg:` breakpoints.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Primary CTA Button
|
||||
|
||||
```tsx
|
||||
<a
|
||||
href="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg
|
||||
bg-sky-600 hover:bg-sky-500 text-white font-medium
|
||||
transition-colors"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
```
|
||||
|
||||
### Secondary / Ghost Button
|
||||
|
||||
```tsx
|
||||
<a
|
||||
href="/features"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg
|
||||
border border-slate-700 hover:border-slate-500
|
||||
text-slate-300 hover:text-slate-100
|
||||
transition-colors"
|
||||
>
|
||||
See all features
|
||||
</a>
|
||||
```
|
||||
|
||||
### Feature Card
|
||||
|
||||
```tsx
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center
|
||||
rounded-lg bg-sky-600/10">
|
||||
{/* Heroicon SVG, className="h-5 w-5 text-sky-400" */}
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-100">Feature name</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Description of the feature in one to three sentences.
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Screenshot Frame
|
||||
|
||||
App screenshots must be wrapped in this frame to integrate naturally
|
||||
against the dark background:
|
||||
|
||||
```tsx
|
||||
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
||||
<img src="/screenshots/device-list.png" alt="Device inventory" className="w-full" />
|
||||
</div>
|
||||
```
|
||||
|
||||
Optionally add a browser chrome header above the image:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||
<span className="ml-4 text-xs text-slate-500 font-mono">netork.local</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Code Block
|
||||
|
||||
```tsx
|
||||
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6
|
||||
font-mono text-sm text-slate-300 overflow-x-auto">
|
||||
<code>{`bash scripts/deploy.sh 192.168.1.1`}</code>
|
||||
</pre>
|
||||
```
|
||||
|
||||
### Driver / Integration Badge
|
||||
|
||||
```tsx
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full
|
||||
border border-slate-700 bg-slate-900
|
||||
text-xs font-medium text-slate-300">
|
||||
OpenWRT
|
||||
</span>
|
||||
```
|
||||
|
||||
### Section Divider
|
||||
|
||||
```tsx
|
||||
<div className="border-t border-slate-800" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
- Sticky top nav: `sticky top-0 z-10 bg-slate-900/80 backdrop-blur
|
||||
border-b border-slate-800`
|
||||
- Logo: left-aligned. Product name in `font-semibold text-slate-100`,
|
||||
optionally prefixed with a small icon.
|
||||
- Nav links: `text-sm text-slate-400 hover:text-slate-100 transition-colors`
|
||||
- Active link: `text-slate-100`
|
||||
- CTA in nav: small primary button `px-4 py-1.5 text-sm`
|
||||
|
||||
---
|
||||
|
||||
## Animations & Transitions
|
||||
|
||||
- **Hover states:** always `transition-colors` (not `transition-all`).
|
||||
- **No JavaScript animations** on initial page load — no entrance animations,
|
||||
no scroll-triggered reveals via IntersectionObserver.
|
||||
- Scroll behavior: `scroll-smooth` on `<html>` for anchor links.
|
||||
- No parallax, no floating elements, no auto-playing videos.
|
||||
|
||||
---
|
||||
|
||||
## Logo / Wordmark
|
||||
|
||||
The netOrk wordmark uses the following convention in the product:
|
||||
- Lowercase `n`, uppercase `O`: **netOrk**
|
||||
- Monospace context: `font-mono text-sky-400`
|
||||
- Heading context: `font-bold text-slate-100` with `Ork` potentially in
|
||||
`text-sky-400` if desired for emphasis
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
Use Heroicons (inline SVG). Sizes:
|
||||
- Feature card icons: `h-5 w-5`
|
||||
- Nav / button icons: `h-4 w-4`
|
||||
- Hero / large decorative: `h-8 w-8` or `h-10 w-10`
|
||||
|
||||
All icons: `text-sky-400` in feature contexts, `text-slate-400` in
|
||||
secondary/muted contexts.
|
||||
|
||||
---
|
||||
|
||||
## tailwind.config.js
|
||||
|
||||
No custom theme extensions needed. The default Tailwind v3 slate + sky
|
||||
palette covers everything. The config only needs content paths:
|
||||
|
||||
```js
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
```
|
||||
Terms from `src/glossary/terms.ts` get a dotted underline and a dark tooltip
|
||||
(`linkify`). Not on the homepage — short copy there stays unmarked.
|
||||
|
||||
+78
-461
@@ -1,466 +1,83 @@
|
||||
# netOrk Website — Page Structure & Content Plan
|
||||
# netOrk website — pages
|
||||
|
||||
This document defines every page of the website: its purpose, section
|
||||
structure, and draft copy. Use this as the brief for implementation.
|
||||
**Audience:** IT departments in small and mid-sized companies — a small team,
|
||||
mixed hardware (OPNsense, HPE ProCurve/Aruba, TP-Link JetStream, OpenWrt,
|
||||
Proxmox, Linux), NIS2 on the agenda. Service providers and IT support are
|
||||
secondary and get their own page each.
|
||||
|
||||
---
|
||||
**One message:** control instead of drift — define the desired state once;
|
||||
netOrk notices every change, shows what deviates and puts it back.
|
||||
|
||||
## Page Overview
|
||||
**One goal:** buy a licence. netOrk itself is free; the licence adds
|
||||
vulnerability data and image updates.
|
||||
|
||||
| Route | Page | Priority |
|
||||
All copy is in `src/i18n/translations.ts`, English and German (German uses
|
||||
"ihr"). The language follows the browser until someone chooses. Every claim
|
||||
must be backed by `docs/PRODUCT.md`; automatic fixing exists for access point
|
||||
profiles, so the site says "every deviation", never "every device fixes itself".
|
||||
|
||||
## Word budgets (English, text in `<main>`)
|
||||
|
||||
`scripts/check/site.py` counts them; Home over budget fails the check.
|
||||
|
||||
| Page | Route | Budget |
|
||||
|---|---|---|
|
||||
| `/` | Landing (Home) | P0 — build first |
|
||||
| `/features` | Full feature list | P1 |
|
||||
| `/drivers` | Supported devices | P1 |
|
||||
| `/docs/getting-started` | Installation guide | P1 |
|
||||
| `/roadmap` | Roadmap — planned + under consideration | P1 |
|
||||
| `/nis2` | NIS2 landing page — Art. 21 mapping, evidence, roadmap | P1 |
|
||||
| `/docs/architecture` | Technical overview | P2 |
|
||||
| `/plugins` | Plugin system | P2 |
|
||||
|
||||
---
|
||||
|
||||
## `/` — Landing Page
|
||||
|
||||
### Section 1 — Hero
|
||||
|
||||
**Purpose:** Answer "what is this?" in 5 seconds.
|
||||
|
||||
**Layout:** Full-width, centered. Heading + subheading + two CTAs + hero
|
||||
screenshot below.
|
||||
|
||||
**Heading:**
|
||||
```
|
||||
Network orchestration
|
||||
for heterogeneous infrastructure.
|
||||
```
|
||||
(`text-slate-100` for first line, second line in `text-sky-400` or keep
|
||||
both `text-slate-100` — designer decides.)
|
||||
|
||||
**Subheading:**
|
||||
```
|
||||
netOrk discovers, monitors, and manages your routers, switches, access
|
||||
points, firewalls, and servers from a single UI — regardless of vendor.
|
||||
No SaaS dependency. Runs on your infrastructure.
|
||||
```
|
||||
|
||||
**CTAs:**
|
||||
- Primary: `Get started →` → `/docs/getting-started`
|
||||
- Secondary: `View features` → `/features`
|
||||
|
||||
**Hero visual:** Full-width screenshot of the device inventory page
|
||||
(dark UI visible, framed with the browser chrome component from DESIGN.md).
|
||||
|
||||
---
|
||||
|
||||
### Section 2 — Problem Statement
|
||||
|
||||
**Purpose:** Make the pain relatable.
|
||||
|
||||
**Layout:** Single centered paragraph or short 3-column stat row.
|
||||
|
||||
**Copy:**
|
||||
```
|
||||
Managing a mixed network means juggling a different admin UI for every
|
||||
vendor — one for OPNsense, one for HP ProCurve, one for OpenWRT, one for
|
||||
Proxmox. Config changes happen directly on devices with no audit trail.
|
||||
You find out something drifted when it breaks.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Section 3 — Core Capabilities (3-up)
|
||||
|
||||
**Purpose:** Communicate the three main things netOrk does.
|
||||
|
||||
**Layout:** 3 columns, each with icon + heading + 2–3 sentences.
|
||||
|
||||
**Card 1 — Discover & Inventory**
|
||||
- Icon: `MagnifyingGlassIcon`
|
||||
- Heading: `Discover everything on your network`
|
||||
- Copy: `ICMP sweep, SNMP scan, and HTTP probing find devices before you
|
||||
add them. Fingerprinting identifies vendor and platform automatically.
|
||||
Adopt results into your inventory with a single click.`
|
||||
|
||||
**Card 2 — Monitor & Alert**
|
||||
- Icon: `ChartBarIcon` or `SignalIcon`
|
||||
- Heading: `Poll device state continuously`
|
||||
- Copy: `Every device is polled on a configurable interval via NAPALM.
|
||||
Interface status, ARP tables, DHCP leases, VLAN membership, Docker
|
||||
containers, and SNMP health metrics — all in one place.`
|
||||
|
||||
**Card 3 — Configure & Enforce**
|
||||
- Icon: `WrenchScrewdriverIcon`
|
||||
- Heading: `Detect drift. Fix it.`
|
||||
- Copy: `Define desired state in netOrk. On every poll, device config is
|
||||
compared against it. Drifted devices get a warning; a one-click fix
|
||||
stream applies the correction and shows you live SSH output.`
|
||||
|
||||
---
|
||||
|
||||
### Section 4 — Driver Grid
|
||||
|
||||
**Purpose:** Show breadth of vendor support.
|
||||
|
||||
**Layout:** Centered heading + wrapping badge grid.
|
||||
|
||||
**Heading:** `Works with your hardware`
|
||||
|
||||
**Subheading:**
|
||||
```
|
||||
netOrk ships with custom NAPALM drivers for 11 device types, plus all
|
||||
built-in NAPALM drivers. New drivers follow a documented registration
|
||||
pattern.
|
||||
```
|
||||
|
||||
**Badge list** (see `docs/PRODUCT.md` — driver table):
|
||||
OpenWRT, OPNsense, Proxmox VE, Linux, HP ProCurve / Aruba, TP-Link Jetstream,
|
||||
Netgear, Fritz!Box, Zyxel, OpenMediaVault, Sonos,
|
||||
Cisco IOS, Arista EOS, Juniper JunOS
|
||||
|
||||
Each badge uses the `Driver / Integration Badge` component from DESIGN.md.
|
||||
|
||||
---
|
||||
|
||||
### Section 5 — Screenshot Walkthrough (alternating)
|
||||
|
||||
**Purpose:** Show the UI concretely. Three alternating image + text rows.
|
||||
|
||||
**Row 1 — Left text, right screenshot**
|
||||
- Heading: `Device detail at a glance`
|
||||
- Copy: `Hostname, IP, vendor, OS version, last poll time, and active
|
||||
warnings on one card. Tabbed detail view for interfaces, LLDP neighbors,
|
||||
ARP table, VLAN membership, packages, services, and scheduled jobs.`
|
||||
- Screenshot: DeviceDetailPage
|
||||
|
||||
**Row 2 — Right text, left screenshot**
|
||||
- Heading: `Intent-based VLAN and SSID management`
|
||||
- Copy: `Define VLAN names and SSID settings once. netOrk compares them
|
||||
against every polled device and pushes corrections automatically via
|
||||
UCI (OpenWRT) or the device's native API.`
|
||||
- Screenshot: VlansPage or WirelessPage
|
||||
|
||||
**Row 3 — Left text, right screenshot**
|
||||
- Heading: `Security visibility per device`
|
||||
- Copy: `Wazuh agent status, CVE counts by severity, and recent alerts
|
||||
— all linked to the device record. One-click agent install if the
|
||||
agent is missing. Graylog syslog forwarding status with auto-fix.`
|
||||
- Screenshot: SecurityTab inside DeviceDetailPage
|
||||
|
||||
**Row 4 — Right text, left screenshot**
|
||||
- Heading: `Configuration backup and versioning`
|
||||
- Copy: `Every poll captures a config snapshot into a local Git
|
||||
repository. The Config tab shows the full snapshot history, a
|
||||
side-by-side diff between any two points in time, and — for
|
||||
OPNsense — a Restore button. Unauthorized changes show up as a
|
||||
device warning.`
|
||||
- Screenshot: ConfigTab inside DeviceDetailPage
|
||||
|
||||
**Row 5 — Left text, right screenshot**
|
||||
- Heading: `Dashboards you actually build`
|
||||
- Copy: `Pick from 13 widgets and arrange them on a WYSIWYG grid — no
|
||||
more fixed layout. Share a dashboard with a colleague, let them
|
||||
subscribe to your live version or clone it into their own, and pin
|
||||
favorites to the main menu.`
|
||||
- Screenshot: DashboardDetailPage (edit mode)
|
||||
|
||||
---
|
||||
|
||||
### Section 5b — NIS2
|
||||
|
||||
**Purpose:** Hook for organizations evaluating netOrk in a NIS2 context.
|
||||
|
||||
**Layout:** Left column — label + Art. 21 mapping list. Right column — mock compliance overview UI.
|
||||
|
||||
**Label (eyebrow):** `NIS2 · Art. 21` (sky-500, uppercase, tracking-widest)
|
||||
|
||||
**Heading:** `Evidence, not paperwork.`
|
||||
|
||||
**Copy:**
|
||||
```
|
||||
NIS2 Art. 21 mandates asset inventory, patch management, access control,
|
||||
and audit trails as baseline technical measures. netOrk doesn't bolt on a
|
||||
compliance layer — these are its day-to-day outputs.
|
||||
```
|
||||
|
||||
**Art. 21 mapping (4 rows, icon = monospace article ref in sky-500):**
|
||||
- Art. 21 (2e) → Patch & vulnerability management — Per-device update status, Wazuh CVE counts by severity
|
||||
- Art. 21 (2h) → Asset management & access control — Full device inventory, RBAC with four roles, complete audit log
|
||||
- Art. 21 (2a) → Risk analysis baseline — Config drift detection, SNMP health metrics, security agent coverage
|
||||
- Art. 21 (2b) → Incident detection — Wazuh alert history, CrowdSec decisions, Graylog syslog per device
|
||||
|
||||
**Mock UI (right column):** `MockCompliance` — per-site checklist with ✓/⚠ rows, each showing label + detail stat. Label: `netork.local / compliance / HQ`.
|
||||
|
||||
---
|
||||
|
||||
### Section 6 — Plugin System (brief)
|
||||
|
||||
**Purpose:** Signal extensibility without going deep.
|
||||
|
||||
**Layout:** Dark card, left-aligned.
|
||||
|
||||
**Heading:** `Built to extend`
|
||||
|
||||
**Copy:**
|
||||
```
|
||||
Integrations (Wazuh, Graylog, CrowdSec, apt-cacher-ng) are plugins
|
||||
that register into the plugin system — they can be enabled or disabled
|
||||
per deployment without code changes. Adding a new integration follows
|
||||
a documented pattern with a hook bus, typed metadata, and a plugin
|
||||
registry.
|
||||
```
|
||||
|
||||
**CTA:** `Plugin system docs →` → `/plugins`
|
||||
|
||||
---
|
||||
|
||||
### Section 7 — Deployment (quick)
|
||||
|
||||
**Purpose:** Answer "how do I run this?" without going into detail.
|
||||
|
||||
**Layout:** Code block + short description.
|
||||
|
||||
**Heading:** `Self-hosted. One command.`
|
||||
|
||||
**Copy:**
|
||||
```
|
||||
netOrk runs in Docker Compose. Five containers: API, two worker pools,
|
||||
a Beat scheduler, and an nginx UI server. No external dependencies beyond
|
||||
Redis and PostgreSQL.
|
||||
```
|
||||
|
||||
**Code block:**
|
||||
```bash
|
||||
# Clone + configure
|
||||
git clone https://gitea.example.com/netork/netork.git
|
||||
cp .env.example .env
|
||||
# edit .env (DB URL, Redis password, secret key)
|
||||
|
||||
# Deploy
|
||||
bash scripts/deploy.sh 192.168.1.10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Section 8 — CTA Footer
|
||||
|
||||
**Layout:** Centered, full-width dark section.
|
||||
|
||||
**Heading:** `Start managing your network.`
|
||||
|
||||
**CTA:** `Read the docs →` → `/docs/getting-started`
|
||||
|
||||
---
|
||||
|
||||
## `/features` — Full Feature List
|
||||
|
||||
**Purpose:** Comprehensive reference for people who want to evaluate in depth.
|
||||
|
||||
**Layout:** Vertical list of expandable sections (or just long-scroll with
|
||||
sticky section nav). One section per capability area.
|
||||
|
||||
**Sections** (map directly to feature list in `docs/PRODUCT.md`):
|
||||
1. Device Management
|
||||
2. Discovery
|
||||
3. VM Provisioning
|
||||
4. Supported Drivers (full table)
|
||||
5. Networking & Inventory
|
||||
6. Configuration Management & Drift
|
||||
7. Configuration Automation (Ansible)
|
||||
8. Scheduled Operations
|
||||
9. Satellite Deployments
|
||||
10. Monitoring & Health
|
||||
11. Dashboards
|
||||
12. Security Integrations
|
||||
13. DNS Management
|
||||
14. RADIUS Management
|
||||
15. Access Control (RBAC)
|
||||
16. NetBox Sync
|
||||
17. Compliance & Audit (NIS2)
|
||||
18. Developer Experience
|
||||
|
||||
Each section: `text-xl font-semibold text-slate-200` heading +
|
||||
feature items as a clean list with `text-slate-400` body.
|
||||
|
||||
---
|
||||
|
||||
## `/drivers` — Supported Devices
|
||||
|
||||
**Purpose:** One-page reference for "does netOrk support my device?"
|
||||
|
||||
**Layout:** Full table + short description per driver.
|
||||
|
||||
**Table columns:** Driver name | Device type | Capabilities | Status
|
||||
|
||||
**Capabilities** — checkmarks or tags for:
|
||||
- `get_facts` `get_interfaces` `get_lldp` `get_vlans` `get_ssids`
|
||||
`get_health_metrics` `get_docker` `scheduled_reboot` `config_push`
|
||||
|
||||
**Status:** `stable` / `beta` / `community` as a badge.
|
||||
|
||||
---
|
||||
|
||||
## `/docs/getting-started` — Installation
|
||||
|
||||
**Purpose:** Get someone from zero to a running instance.
|
||||
|
||||
**Sections:**
|
||||
|
||||
1. **Prerequisites**
|
||||
- Docker + Docker Compose
|
||||
- A PostgreSQL instance (or use the bundled profile)
|
||||
- Redis
|
||||
- A Linux host reachable by SSH from the server
|
||||
|
||||
2. **Quick start**
|
||||
```bash
|
||||
git clone ...
|
||||
cp .env.example .env
|
||||
# Edit .env
|
||||
bash scripts/deploy.sh <server-ip>
|
||||
```
|
||||
|
||||
3. **First run**
|
||||
- Navigate to `http://<server-ip>`
|
||||
- Complete the setup wizard (creates admin user)
|
||||
- Add your first device
|
||||
|
||||
4. **Adding a device**
|
||||
- Fill in hostname/IP, driver, and credentials
|
||||
- Click Poll to verify connectivity
|
||||
- Set a poll interval for continuous monitoring
|
||||
|
||||
5. **Next steps**
|
||||
- Configure NetBox sync
|
||||
- Set up Wazuh integration
|
||||
- Enable scheduled reboots for OpenWRT APs
|
||||
|
||||
---
|
||||
|
||||
## `/roadmap` — Roadmap
|
||||
|
||||
**Purpose:** Show what's being built and what's under consideration. Signal NIS2 investment clearly.
|
||||
|
||||
**Layout:** Page header + two vertical groups ("Planned" / "Under consideration"), each a list of items.
|
||||
|
||||
**NIS2 badge:** `NIS2` monospace tag (sky-500/10 bg, sky-400 text, sky-500/20 border) inline next to item title.
|
||||
|
||||
**Intro copy:**
|
||||
```
|
||||
What's being built and what's being evaluated. Items tagged NIS2 directly
|
||||
address NIS2 Art. 21 technical baseline requirements.
|
||||
```
|
||||
|
||||
**Planned items (NIS2-tagged):**
|
||||
- CVE tracking per device — NVD / OSV cross-reference
|
||||
- Compliance dashboard — per-site Art. 21 checklist view
|
||||
|
||||
**Planned items (general):**
|
||||
- Webhook engine — outbound events with HMAC signing
|
||||
- Live job log streaming — WebSocket for all long-running tasks
|
||||
- NetBox sync — manual trigger + status view
|
||||
|
||||
**Under consideration (NIS2-tagged):**
|
||||
- Incident workflow — structured record + NIS2 Art. 23 Fristen-Tracker
|
||||
|
||||
**Under consideration (general):**
|
||||
- mDNS scanner — media device discovery
|
||||
- Prometheus + Grafana — metrics and dashboards
|
||||
- Kubernetes Helm chart
|
||||
|
||||
---
|
||||
|
||||
## `/docs/architecture` — Technical Overview
|
||||
|
||||
**Purpose:** Give engineers the mental model before they look at code.
|
||||
|
||||
**Content:** Essentially the one-paragraph summary from `docs/PRODUCT.md`
|
||||
expanded into a readable overview with the architecture diagram (ASCII or SVG).
|
||||
|
||||
**Sections:**
|
||||
1. Overview (request → FastAPI → DB / Celery worker)
|
||||
2. Driver system (NAPALM + custom drivers + registry)
|
||||
3. Task queues (which queue does what)
|
||||
4. Plugin system (register → hook bus → router mount)
|
||||
5. Data model (UUID PKs, JSONB snapshots, intent-vs-state)
|
||||
|
||||
---
|
||||
|
||||
## `/plugins` — Plugin System
|
||||
|
||||
**Purpose:** Explain extensibility to potential contributors.
|
||||
|
||||
**Sections:**
|
||||
1. What is a plugin? (metadata, router, tasks, hooks)
|
||||
2. Built-in plugins (Wazuh, Graylog, CrowdSec, apt-cacher)
|
||||
3. Writing a plugin (step-by-step with code snippets)
|
||||
4. Hook bus (fire / call / transform)
|
||||
5. Plugin registry and enable/disable
|
||||
|
||||
---
|
||||
|
||||
## `/for/*` — Persona Pages
|
||||
|
||||
**Purpose:** Answer "is this for me?" from the perspective of a specific
|
||||
buyer/user, instead of one generic homepage pitch. Reachable via the "Für
|
||||
wen" / "Who it's for" nav dropdown.
|
||||
|
||||
**Shared layout:** hero (icon + heading + sub) → "Your day today" pain-point
|
||||
cards (persona-specific, concrete workflow friction) → feature-callout cards
|
||||
(only shipped capabilities, cited from `docs/PRODUCT.md`) → CTA block linking
|
||||
to `/docs/getting-started`. Same card/section classes as `/plugins`.
|
||||
|
||||
- **`/for/it-department`** — core admin/engineer audience. Pain points:
|
||||
per-vendor admin UIs, no single inventory view, undocumented config
|
||||
changes, manual SSH just to check state. Features: Device Management,
|
||||
config drift + one-click fix, Git-backed config history, Ansible
|
||||
automation, VM Provisioning, Dashboards. Plus a short supported-drivers
|
||||
strip linking to `/drivers`.
|
||||
- **`/for/it-support`** — day-to-day operators, less config depth. Pain
|
||||
points: "is it up right now?", repeated manual reboots, no change record,
|
||||
full admin access for one ticket. Features: warning system + dashboard
|
||||
widget, one-click Ack, Wake-on-LAN, scheduled reboots/updates, filterable
|
||||
audit log + export, roles scoped below engineer level.
|
||||
- **`/for/msp`** — managed service providers, strongest standalone buying
|
||||
case. Pain points: unreachable client sites, no cross-client view, proving
|
||||
what was done, client data in someone else's cloud. Features: Satellite
|
||||
Deployments, automatic routing around unreachable sites (with the honest
|
||||
caveat that SNMP metrics + WebSSH still need direct reach), audit trail as
|
||||
client-facing evidence, per-technician custom roles (**not** phrased as
|
||||
per-site RBAC — netOrk's roles are global permission sets, not
|
||||
site-scoped), self-hosted/no per-seat SaaS.
|
||||
|
||||
The existing `/nis2` page (security/compliance persona) is linked from the
|
||||
same dropdown rather than duplicated.
|
||||
|
||||
---
|
||||
|
||||
## Global Layout
|
||||
|
||||
### Navigation (all pages)
|
||||
|
||||
```
|
||||
[ netOrk ] Features Drivers Docs ▾ Für wen ▾ Plugins Roadmap [ Get started ]
|
||||
```
|
||||
|
||||
`Docs ▾`: Getting Started / Architecture / NIS2 Compliance / Glossary
|
||||
`Für wen ▾`: IT Department / IT Support / MSP / NIS2 Compliance (reuses the Docs dropdown's NIS2 link/label)
|
||||
|
||||
### Footer
|
||||
|
||||
```
|
||||
netOrk — self-hosted network orchestration
|
||||
|
||||
Links: Resources: Legal:
|
||||
Features Getting Started MIT License
|
||||
Drivers Architecture Privacy (none collected)
|
||||
Plugins Changelog
|
||||
Roadmap NIS2
|
||||
Glossary
|
||||
For IT Departments
|
||||
For IT Support
|
||||
For MSPs
|
||||
```
|
||||
|
||||
Footer background: `bg-slate-900 border-t border-slate-800`
|
||||
Footer text: `text-sm text-slate-500`
|
||||
| Home | `/` | 400 |
|
||||
| Pricing | `/pricing` | 300 |
|
||||
| NIS2 | `/nis2` | 550 |
|
||||
| Plugins | `/plugins` | 450 |
|
||||
| Persona ×3 | `/for/it-department`, `/for/it-support`, `/for/msp` | 320 |
|
||||
| Getting started | `/docs/getting-started` | 80 |
|
||||
| Glossary, Impressum, Datenschutz | `/glossary`, `/impressum`, `/datenschutz` | — |
|
||||
|
||||
Old routes redirect (in `App.tsx` and as 301 in `nginx.conf`): `/features` →
|
||||
`/#included`, `/drivers` → `/#hardware`, `/roadmap` and `/docs/architecture` → `/`.
|
||||
|
||||
## Home
|
||||
|
||||
1. **Hero** — "Control instead of drift." The drift comparison of an access
|
||||
point (`drift`, `drift-narrow` on phones) with a caption saying what is in it.
|
||||
2. **How it works** (`#how`) — Define → Detect → Fix, three steps.
|
||||
3. **Hardware** (`#hardware`) — two lines of names: "desired state and fixes"
|
||||
and "inventory and monitoring"; a note on the untested NAPALM drivers.
|
||||
4. **Vulnerabilities, with a licence** — the triage queue (`vulnerabilities`),
|
||||
link to Pricing.
|
||||
5. **Also in the box** (`#included`) — eight terms, one short line each; a NIS2
|
||||
row with a link.
|
||||
6. **Closing band** — "netOrk is free. The licence adds the data."
|
||||
|
||||
## Pricing
|
||||
|
||||
The free core as one row, then Starter / Pro / Enterprise, each "on request"
|
||||
with a "Buy a licence" button to the licence portal (`src/data/plans.ts`). Plan
|
||||
differences come from the licence server's plan defaults; the KB request limit
|
||||
stays off the page until it has a unit. Four questions below, and the note that
|
||||
there is no public installer yet.
|
||||
|
||||
## Persona pages
|
||||
|
||||
One template (`Persona.tsx`): header, three problems, five ways netOrk helps
|
||||
(desired state and drift first), the shared closing band. The IT-department
|
||||
page shows the drift screenshot. The service-provider page says where it
|
||||
stops today.
|
||||
|
||||
## NIS2
|
||||
|
||||
Article by article (eight rows plus one "out of scope"), a dot and a word for
|
||||
coverage, what netOrk records along the way, the audit log screenshot. States
|
||||
plainly that netOrk does not make anyone compliant.
|
||||
|
||||
## Plugins, Glossary, Getting started, Impressum, Datenschutz
|
||||
|
||||
Plugins: the five included ones with the hosts they talk to, how to write one,
|
||||
one code example. Glossary: every term from `src/glossary/terms.ts` with a
|
||||
category index. Getting started: "no public installer yet", write to us.
|
||||
Impressum and Datenschutz: **placeholders** — the final text must replace the
|
||||
yellow box before netork.io goes live.
|
||||
|
||||
## Navigation and footer
|
||||
|
||||
Nav: wordmark · Who it's for ▾ · NIS2 · Plugins · Pricing · DE/EN · "Buy a
|
||||
licence". Below `lg` a menu button. Footer: product links, persona links,
|
||||
contact, "no cookies, no tracking, no requests to anyone else", © line with
|
||||
Impressum and Datenschutz.
|
||||
|
||||
+97
-15
@@ -1,5 +1,22 @@
|
||||
# netOrk — Product Description
|
||||
|
||||
## Positioning (since 2026-09)
|
||||
|
||||
- **Message:** control instead of drift. Define once how the network should be
|
||||
set up; netOrk notices every change on access points, switches and firewalls,
|
||||
shows what deviates and puts it back. Automatic fixing exists for access point
|
||||
profiles; firewall profiles are compared and applied on demand; switch VLANs
|
||||
are provisioned centrally; every configuration change is versioned in Git and
|
||||
flagged when netOrk did not make it.
|
||||
- **Primary audience:** IT departments in small and mid-sized companies.
|
||||
- **Licence model:** netOrk itself is free. A licence (Starter / Pro /
|
||||
Enterprise, price on request, sold through the licence portal) adds
|
||||
vulnerability data from the netOrk Knowledge Base and image updates. One key
|
||||
per netOrk instance; no limits on devices, sites or users. Plan differences:
|
||||
vulnerability history 90 days / 1 year / 10 years, match evidence and CWE
|
||||
details from Pro, the edge update channel for Enterprise. Source:
|
||||
license-server plan defaults, mirrored in `src/data/plans.ts`.
|
||||
|
||||
## One-liner
|
||||
|
||||
**netOrk is a self-hosted network orchestration platform that discovers,
|
||||
@@ -95,12 +112,22 @@ hardware and want operational visibility beyond what consumer dashboards offer.
|
||||
- Vendor/model/OS auto-populated from NAPALM `get_facts()`
|
||||
- Site assignment with FK to structured Site records
|
||||
- AP Profile assignment for grouped OpenWRT config
|
||||
- Web SSH terminal: sessions log in with each user's own SSH key, never the
|
||||
device's shared account; opened and refused sessions are recorded. Sessions
|
||||
are movable, dockable windows that survive navigating away
|
||||
- A device can hold several roles at once (e.g. storage + hypervisor + Linux)
|
||||
- One device per address per site; duplicates are refused (VMs exempt)
|
||||
- Business criticality per device and site, used in vulnerability ranking
|
||||
|
||||
### Discovery
|
||||
- ICMP ping sweep, SNMP scan, HTTP/HTTPS probing
|
||||
- Device fingerprinting: vendor + platform confidence scoring
|
||||
- FQDN resolution (reverse DNS)
|
||||
- Manual adoption from scan results (no auto-create to avoid inventory noise)
|
||||
- Discovery jobs in a sortable, filterable table, grouped per site
|
||||
- LAN Scan: ping sweep from netOrk, each site satellite and every firewall;
|
||||
live results with MAC and manufacturer; a finished scan becomes a discovery
|
||||
job in one step
|
||||
|
||||
### VM Provisioning
|
||||
- Cloud-Init based VM creation directly from a hypervisor's VMs tab — no
|
||||
@@ -124,23 +151,32 @@ Custom NAPALM drivers for all of the following:
|
||||
|
||||
| Driver | Device type |
|
||||
|---|---|
|
||||
| `openwrt` | OpenWRT access points |
|
||||
| `opnsense` | OPNsense firewalls |
|
||||
| `proxmox` | Proxmox VE hypervisors |
|
||||
| `fritzbox` | AVM Fritz!Box routers (read-only) |
|
||||
| `hpe_officeconnect` | HPE OfficeConnect 1820 / 1920S switches |
|
||||
| `linux` | Generic Linux servers |
|
||||
| `procurve` | HP ProCurve / Aruba switches |
|
||||
| `tplink_jetstream` | TP-Link Jetstream managed switches |
|
||||
| `netgear` | Netgear switches |
|
||||
| `fritzbox` | AVM Fritz!Box routers |
|
||||
| `zyxel` | Zyxel switches |
|
||||
| `netgear_plus` | Netgear Plus switches (web UI) |
|
||||
| `netgear_smart` | Netgear Smart Managed Pro switches |
|
||||
| `openmediavault` | OpenMediaVault NAS |
|
||||
| `openwrt` | OpenWrt routers and access points |
|
||||
| `opnsense` | OPNsense firewalls |
|
||||
| `procurve` | HPE ProCurve / Aruba switches |
|
||||
| `proxmox` | Proxmox VE hypervisors |
|
||||
| `qnap_qts` | QNAP NAS on QTS |
|
||||
| `sonos` | Sonos speakers |
|
||||
| `tplink_jetstream` | TP-Link JetStream managed switches |
|
||||
| `yealink` | Yealink IP phones |
|
||||
| `zyxel` | Zyxel VMG routers (not switches) |
|
||||
|
||||
Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper JunOS.
|
||||
The built-in NAPALM drivers (Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper JunOS)
|
||||
are installed but not tested with netOrk and get none of its driver-specific
|
||||
features. Capability matrix (audited against v0.28.0): see `src/pages/Drivers.tsx`.
|
||||
Reboot from netOrk actually restarts only OpenWrt and Proxmox.
|
||||
|
||||
### Networking & Inventory
|
||||
- Interface browser with IPv4/IPv6 addresses, MAC, speed, MTU
|
||||
- LLDP neighbor discovery and topology graph
|
||||
- LLDP neighbor discovery and topology graph, plus links derived from switch
|
||||
MAC tables (drawn dashed)
|
||||
- Radio problems between the access points of a site are reported
|
||||
- ARP table and DHCP lease browser per device
|
||||
- Subnet browser with interface-to-subnet assignments
|
||||
- VLAN list grouped by site; per-VLAN device membership view
|
||||
@@ -170,8 +206,10 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
|
||||
### Configuration Automation (Ansible)
|
||||
- Reusable Ansible roles and playbooks stored and edited directly in
|
||||
netOrk — no separate git checkout
|
||||
- 11 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy,
|
||||
portainer, watchtower, uptime-kuma, vaultwarden, wireguard, fail2ban
|
||||
- 16 built-in roles ready to assign: base, ubuntu, docker, adguard, zoraxy,
|
||||
portainer, watchtower, uptime-kuma, vaultwarden, stalwart, bulwark, searxng,
|
||||
postiz, listmonk, wireguard, fail2ban
|
||||
- Roles state their resource needs; undersized hosts are refused with a reason
|
||||
- Automatic dependency resolution — assigning `docker` pulls in `base`
|
||||
automatically, no manual role ordering
|
||||
- Built-in roles can't be deleted but are fully editable; customizations
|
||||
@@ -215,7 +253,7 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
|
||||
- One-click Ack on any warning — clears it immediately and writes an audit log
|
||||
entry; for config-change warnings the current state is accepted as the new
|
||||
baseline
|
||||
- Docker container and image status (Proxmox/Linux)
|
||||
- Docker container and image status (Linux, OpenMediaVault, QNAP)
|
||||
- Service status and start/stop/restart (systemd)
|
||||
- VM/container list with OS device cross-linking (Proxmox)
|
||||
- Per-device availability windows — suppress OFFLINE status and poll-failure
|
||||
@@ -225,21 +263,65 @@ Plus all built-in NAPALM drivers: Cisco IOS/IOS-XE/NX-OS, Arista EOS, Juniper Ju
|
||||
- OPNsense: TLS certificate monitoring for the Trust store, with
|
||||
expiring-soon / expired warnings
|
||||
- OPNsense: Dynamic DNS service-down warning (os-ddclient)
|
||||
- Service checks about once a minute (DNS, NTP, VPN tunnels, core daemons,
|
||||
gateways), derived automatically; three failures before an alert; can run
|
||||
from satellites, including a DHCP check
|
||||
- Site reachability: polling pauses behind a dead tunnel, one warning names
|
||||
it, everything is re-polled when it returns
|
||||
|
||||
### Dashboards
|
||||
- Configurable, shareable dashboards — build your own from a widget picker
|
||||
instead of a fixed layout
|
||||
- WYSIWYG grid-layout editor: drag, resize, and arrange widgets on a canvas
|
||||
- 13 widget types: stats, device warnings, recently updated devices, network
|
||||
- 18 widget types: stats, device warnings, recently updated devices, network
|
||||
topology, EOL status, config drift summary, Wazuh security alerts, audit log
|
||||
activity, discovery jobs status, upcoming scheduled actions, DNS zones
|
||||
overview, site overview, config snapshot history
|
||||
overview, site overview, config snapshot history, managed services,
|
||||
certificate expiry, outdated Docker images, firewall profile deployment
|
||||
status, service checks
|
||||
- Multi-instance widgets with independent per-widget settings
|
||||
- Share a dashboard with specific users; recipients can subscribe to the
|
||||
owner's live version or clone it into their own editable copy
|
||||
- Favorite dashboards for quick access from the main menu; set any dashboard
|
||||
as your home view
|
||||
|
||||
### Notifications
|
||||
- Signal messages for everything netOrk watches; each person registers their
|
||||
own number, administrators pair netOrk once via QR code
|
||||
- One message per site outage, daily summary for recurring items, hourly
|
||||
bundling, quiet hours per number, mute per kind, full history with reasons
|
||||
|
||||
### DHCP
|
||||
- DHCP reservations: import from the firewall, validated, diff, then apply
|
||||
(adds and updates only)
|
||||
- DHCP subnets (Kea on OPNsense) with options and search domains; settings
|
||||
that break a network are refused
|
||||
|
||||
### Managed Services
|
||||
- Every container-based service across devices with endpoints, TLS
|
||||
certificates and access rules
|
||||
- Compose editor with masked secrets and automatic backup snapshot; redeploy
|
||||
is a separate confirmed step
|
||||
- Zoraxy vhosts editable and written back; PostgreSQL databases listed
|
||||
|
||||
### Security Assessment
|
||||
- Security tab per device: TLS/SSH grades A–F, installed software and
|
||||
container images matched against known vulnerabilities, hardening benchmarks
|
||||
- Ratings adjusted to the device (local access, trusted network, not running,
|
||||
not booted kernel; raised when exploited in the wild)
|
||||
- Kernel reboot recommendation with the vulnerabilities it would clear
|
||||
- Exposure from firewall rules; internet-visible ports and abuse reports for
|
||||
own public addresses; on-demand hardening audit and web scan
|
||||
- Vulnerability data from the netOrk Knowledge Base (licence required)
|
||||
|
||||
### Vulnerability Management
|
||||
- Triage queue across all devices, one row per vulnerability, ordered by
|
||||
remediation deadline, exploitation, severity, likelihood, criticality, spread
|
||||
- Decisions (not applicable / accept until / defer until / fixed) with a
|
||||
mandatory reason; accept and not-applicable need an elevated permission
|
||||
- Deferred and accepted items return by themselves; ignored ones go overdue
|
||||
- Daily reassessment verifies fixes and reopens regressions
|
||||
|
||||
### Security Integrations (plugins)
|
||||
- **Wazuh** — agent enrollment tracking, vulnerability counts (by severity),
|
||||
recent alert history, CIS benchmark scores, one-click agent install fix stream
|
||||
|
||||
+6
-5
@@ -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
@@ -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;
|
||||
|
||||
Generated
+10
-39
@@ -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": [
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@heroicons/react": "^2.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
@@ -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())
|
||||
@@ -0,0 +1,29 @@
|
||||
# Demo instance for screenshots
|
||||
|
||||
The website shows real netOrk screens, taken from a local copy of a production
|
||||
database with every hostname, domain, address, MAC and name replaced.
|
||||
|
||||
```
|
||||
pg_dump -Fc ... > netork.dump # on the production host, by hand
|
||||
scripts/demo/up.sh restore netork.dump # fresh local DB + anonymize.py
|
||||
scripts/demo/up.sh start # API :8000, UI http://127.0.0.1:5173
|
||||
scripts/screenshots/capture.py --list-devices
|
||||
scripts/screenshots/capture.py --var ap=<id> --var switch=<id> --var server=<id>
|
||||
```
|
||||
|
||||
Log in as `netork` / `netork-demo`.
|
||||
|
||||
- Only the API and the UI run. There is no worker, no beat and no Redis, so
|
||||
nothing polls or reaches a device. Stored credentials are emptied, and the
|
||||
encryption key is random per start.
|
||||
- Secrets are emptied — except the ones netOrk compares with each other (Wi-Fi
|
||||
keys on an SSID against the key read from the access point). Those become a
|
||||
keyed hash, so equal stays equal and the drift view shows the real state
|
||||
instead of invented deviations. The key exists only for one run.
|
||||
- The mapping from real to demo names lives outside the repo in
|
||||
`~/.config/netork-screenshots/demo-map.json`, because it lists the real names.
|
||||
Domains become `example.demo`.
|
||||
- `anonymize.py` ends with a leak report. Read it before taking screenshots,
|
||||
and look at every image before committing it.
|
||||
- The dump file itself holds production data: keep it out of the repo and
|
||||
delete it when done.
|
||||
Executable
+470
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn a restored copy of a production netOrk database into demo data.
|
||||
|
||||
anonymize.py [--dsn postgresql://...] [--map demo-map.json] [--dry-run]
|
||||
|
||||
Run it against the LOCAL copy only; it refuses anything that is not
|
||||
localhost. It works on every text-like column of every table instead of a
|
||||
hand-kept list, so a table added in a later release is covered too:
|
||||
|
||||
* domains every configured domain (e.g. corp.example.com, acme.io) becomes
|
||||
`example.demo`, subdomains kept: gw.home.corp.example.com ->
|
||||
gw.home.example.demo
|
||||
* IPv4 private addresses move to another /16 per /16, host part kept,
|
||||
so subnets and VLAN plans still line up; public addresses are
|
||||
mapped one by one into the documentation ranges
|
||||
* IPv6 global prefixes go to 2001:db8::/32, interface IDs are hashed
|
||||
* MAC the vendor prefix (OUI) is kept, so manufacturer lookups still
|
||||
work; the device part is hashed
|
||||
* e-mail local part hashed, domain example.demo
|
||||
* names hostnames, site names, VLAN names, user names ... from the map
|
||||
* secrets stored credentials, keys, tokens, TOTP and secret settings are
|
||||
emptied; one admin `netork` with a known password is left
|
||||
|
||||
Every mapping is deterministic, so the same address always turns into the
|
||||
same fake one, across tables, JSON documents and log lines alike. At the end
|
||||
a leak report lists anything that still looks like the original.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
DEFAULT_DSN = "postgresql://netork:demo@127.0.0.1:55432/netork"
|
||||
DEFAULT_MAP = Path.home() / ".config" / "netork-screenshots" / "demo-map.json"
|
||||
DEMO_DOMAIN = "example.demo"
|
||||
|
||||
# Public reference data: large, and nothing in it is about the instance.
|
||||
SKIP_TABLES = {
|
||||
"alembic_version", "cwe_entries", "epss_scores", "nvd_cpe_matches",
|
||||
"nvd_cpe_products", "nvd_cve_requirements", "nvd_cves", "osv_affected",
|
||||
"osv_vulns", "oui_vendors", "service_templates",
|
||||
}
|
||||
TEXT_TYPES = {"text", "character varying", "jsonb", "json", "inet", "cidr", "macaddr", "ARRAY"}
|
||||
|
||||
# Only these count as internal addresses to move; Python's is_private also
|
||||
# covers 0.0.0.0/8 and friends, which in practice are version numbers.
|
||||
PRIVATE_NETS = [ipaddress.IPv4Network(n) for n in
|
||||
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")]
|
||||
|
||||
# Well-known public resolvers stay as they are; they say nothing about anyone.
|
||||
KEEP_PUBLIC = {"1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4", "9.9.9.9", "149.112.112.112"}
|
||||
|
||||
IPV4 = re.compile(r"(?<![\d.])((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})(?!\d|\.\d)")
|
||||
MAC = re.compile(r"(?<![0-9A-Fa-f:-])([0-9A-Fa-f]{2}([:-])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2})(?![0-9A-Fa-f:-])")
|
||||
MAC_DOT = re.compile(r"(?<![0-9A-Fa-f.])([0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4})(?![0-9A-Fa-f.])")
|
||||
IPV6 = re.compile(r"(?<![0-9A-Fa-f:])((?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4})(?![0-9A-Fa-f:])")
|
||||
# Reverse zones and PTR names: 8.22.172.in-addr.arpa is 172.22.8.0/24.
|
||||
REVERSE = re.compile(r"(?<![\d.])((?:\d{1,3}\.){1,4})in-addr\.arpa", re.I)
|
||||
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}")
|
||||
|
||||
|
||||
def h(value: str, n: int) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()[:n]
|
||||
|
||||
|
||||
class Mapper:
|
||||
def __init__(self, cfg: dict):
|
||||
# {"home.corp.example.com": "hq.example.demo", "corp.example.com": "example.demo"}
|
||||
self.domains: dict[str, str] = cfg.get("domains", {})
|
||||
self.prefix16 = dict(cfg.get("ipv4_prefix16", {}))
|
||||
taken = set(self.prefix16.values())
|
||||
pool = cfg.get("ipv4_pool16") or (
|
||||
[f"10.{n}" for n in range(20, 256, 10)] + [f"10.{n}" for n in range(256) if n % 10]
|
||||
+ [f"172.{n}" for n in range(16, 32)])
|
||||
self.pool16 = iter(p for p in pool if p not in taken)
|
||||
self.public: dict[str, str] = {}
|
||||
self.public_used: set[str] = set()
|
||||
# Public-looking dotted quads are only mapped once they were seen as an
|
||||
# address (see collect_public); "kernel 6.8.0.45" is a version, not a host.
|
||||
self.known_public: set[str] = set(cfg.get("public_ips", []))
|
||||
self.unmapped_public: dict[str, int] = {}
|
||||
self.public_pool = iter(
|
||||
[f"203.0.113.{n}" for n in range(10, 250)] + [f"198.51.100.{n}" for n in range(10, 250)])
|
||||
names = {**cfg.get("hostnames", {}), **cfg.get("terms", {})}
|
||||
self.names = names
|
||||
self.names_re = None
|
||||
if names:
|
||||
alt = "|".join(re.escape(k) for k in sorted(names, key=len, reverse=True))
|
||||
# A name is a whole token: not glued to letters, digits, '-' or '_'.
|
||||
self.names_re = re.compile(rf"(?<![\w-])({alt})(?![\w-])")
|
||||
# Plain substrings, for names glued into identifiers (HOME_OFFICE_MGMT_NET).
|
||||
self.substrings: dict[str, str] = cfg.get("substrings", {})
|
||||
self.domain_re = None
|
||||
if self.domains:
|
||||
alt = "|".join(re.escape(d) for d in sorted(self.domains, key=len, reverse=True))
|
||||
# Lazy prefix, so the longest configured domain wins.
|
||||
self.domain_re = re.compile(rf"(?<![\w-])((?:[\w-]+\.)*?)({alt})(?![\w-])", re.I)
|
||||
|
||||
# -- single values -------------------------------------------------------
|
||||
def ipv4(self, ip: str) -> str:
|
||||
a = ipaddress.IPv4Address(ip)
|
||||
if ip in KEEP_PUBLIC or a.is_loopback or a.is_multicast or a.is_unspecified \
|
||||
or a.is_link_local or ip.startswith("255.") or a.is_reserved:
|
||||
return ip
|
||||
if any(a in net for net in PRIVATE_NETS):
|
||||
p = ".".join(ip.split(".")[:2])
|
||||
if p not in self.prefix16:
|
||||
self.prefix16[p] = next(self.pool16)
|
||||
return self.prefix16[p] + "." + ".".join(ip.split(".")[2:])
|
||||
if not a.is_global:
|
||||
return ip # 0.x, 192.0.0.x, benchmark ... : versions more often than hosts
|
||||
if ip not in self.known_public:
|
||||
self.unmapped_public[ip] = self.unmapped_public.get(ip, 0) + 1
|
||||
return ip
|
||||
if ip not in self.public:
|
||||
fake = next(self.public_pool, None)
|
||||
probe = 0
|
||||
while fake is None or fake in self.public_used:
|
||||
# Documentation ranges exhausted (CrowdSec alone brings tens of
|
||||
# thousands of attacker addresses): hash into the non-routable
|
||||
# benchmark range 198.18.0.0/15, probing on collision.
|
||||
n = int(h(f"{ip}/{probe}", 8), 16) % (2 ** 17)
|
||||
fake = f"198.{18 + (n >> 16)}.{(n >> 8) & 255}.{n & 255}"
|
||||
probe += 1
|
||||
self.public_used.add(fake)
|
||||
self.public[ip] = fake
|
||||
return self.public[ip]
|
||||
|
||||
def mac(self, m: str) -> str:
|
||||
sep = m[2]
|
||||
hexs = m.replace(sep, "")
|
||||
new = hexs[:6] + h(hexs.lower(), 6)
|
||||
new = new.upper() if hexs.isupper() else new.lower()
|
||||
return sep.join(new[i:i + 2] for i in range(0, 12, 2))
|
||||
|
||||
def mac_dot(self, m: str) -> str:
|
||||
hexs = m.replace(".", "")
|
||||
new = hexs[:6] + h(hexs.lower(), 6)
|
||||
return ".".join(new[i:i + 4] for i in range(0, 12, 4))
|
||||
|
||||
def ipv6(self, s: str) -> str:
|
||||
# "Data::" or "12:30:45" are no addresses; demand three real groups.
|
||||
if sum(1 for g in s.split(":") if g) < 3:
|
||||
return s
|
||||
try:
|
||||
a = ipaddress.IPv6Address(s)
|
||||
except ValueError:
|
||||
return s # a time like 12:30:45 or similar, not an address
|
||||
if a.is_loopback or a.is_unspecified or a.is_multicast:
|
||||
return s
|
||||
iid = h(a.packed[8:].hex(), 16)
|
||||
if a.is_link_local:
|
||||
prefix = "fe80:0000:0000:0000"
|
||||
elif a.is_private: # ULA fd00::/8, keep it ULA
|
||||
prefix = "fd00:" + h(a.packed[:8].hex(), 12)
|
||||
prefix = prefix[:4] + ":" + prefix[5:9] + ":" + prefix[9:13] + ":" + prefix[13:17].ljust(4, "0")
|
||||
else:
|
||||
p = h(a.packed[:8].hex(), 8)
|
||||
prefix = f"2001:0db8:{p[:4]}:{p[4:]}"
|
||||
full = prefix + ":" + ":".join(iid[i:i + 4] for i in range(0, 16, 4))
|
||||
return str(ipaddress.IPv6Address(full))
|
||||
|
||||
def email(self, m: re.Match) -> str:
|
||||
e = m.group(0)
|
||||
if e.endswith("@" + DEMO_DOMAIN):
|
||||
return e
|
||||
return f"user-{h(e.lower(), 6)}@{DEMO_DOMAIN}"
|
||||
|
||||
def reverse(self, m: re.Match) -> str:
|
||||
octets = m.group(1).rstrip(".").split(".")[::-1] # forward order
|
||||
if len(octets) < 2 or any(int(o) > 255 for o in octets):
|
||||
return m.group(0)
|
||||
padded = octets + ["0"] * (4 - len(octets))
|
||||
mapped = self.ipv4(".".join(padded)).split(".")[:len(octets)]
|
||||
return ".".join(mapped[::-1]) + ".in-addr.arpa"
|
||||
|
||||
# -- whole strings -------------------------------------------------------
|
||||
@staticmethod
|
||||
def _secret(m: re.Match) -> str:
|
||||
name, sep = m.group(1), m.group(2)
|
||||
if name in SECRET_JSON_KEEP:
|
||||
return m.group(0)
|
||||
raw = m.group(0)[m.group(0).index(sep) + len(sep) + 1:-1]
|
||||
if not raw or not COMPARED_SECRET.match(name):
|
||||
return f'"{name}"{sep}""'
|
||||
value = json.loads(f'"{raw}"') # the value as the column would hold it
|
||||
return f'"{name}"{sep}"{secret_token(value)}"'
|
||||
|
||||
def text(self, s: str) -> str:
|
||||
s = SECRET_JSON.sub(self._secret, s)
|
||||
s = REVERSE.sub(self.reverse, s)
|
||||
s = EMAIL.sub(self.email, s)
|
||||
if self.domain_re:
|
||||
s = self.domain_re.sub(lambda m: m.group(1) + self.domains[m.group(2).lower()], s)
|
||||
s = MAC.sub(lambda m: self.mac(m.group(1)), s)
|
||||
s = MAC_DOT.sub(lambda m: self.mac_dot(m.group(1)), s)
|
||||
s = IPV6.sub(lambda m: self.ipv6(m.group(1)), s)
|
||||
s = IPV4.sub(lambda m: self.ipv4(m.group(1)), s)
|
||||
if self.names_re:
|
||||
s = self.names_re.sub(lambda m: self.names[m.group(1)], s)
|
||||
for old, new in self.substrings.items():
|
||||
s = s.replace(old, new)
|
||||
return s
|
||||
|
||||
|
||||
# Cheap server-side prefilter: only rows that could contain something to map.
|
||||
def prefilter(cfg: dict) -> str:
|
||||
parts = [r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", r"[0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-]",
|
||||
r"[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.", r"[0-9A-Fa-f]{1,4}::?[0-9A-Fa-f]{1,4}:", "@",
|
||||
r"in-addr\.arpa", r"(key|psk|passphrase|password|secret|token)\"\s*:"]
|
||||
for k in [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
|
||||
*cfg.get("substrings", {})]:
|
||||
parts.append(re.escape(k))
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
# Columns emptied wherever they occur, found by name so a new table is covered.
|
||||
SECRET_COLUMN = re.compile(r"(password|secret|private_key|api_key|apikey|token|passphrase|psk|ft_key|wpa_key)", re.I)
|
||||
# The same inside JSON and text: device snapshots carry Wi-Fi keys and the like.
|
||||
SECRET_JSON = re.compile(
|
||||
r'"((?:[A-Za-z0-9_]*_)?(?:key|psk|passphrase|password|passwd|secret|token|private_key|ft_key|sae_password))"'
|
||||
r'(\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||
SECRET_JSON_KEEP = {"public_key", "entry_key", "key_type", "is_secret", "ssh_key_id"}
|
||||
|
||||
# Secrets netOrk compares with each other (the Wi-Fi key stored on an SSID against
|
||||
# the key read from the access point). Emptying them would invent drift that never
|
||||
# existed, so they become a keyed hash instead: equal stays equal, nothing can be
|
||||
# reversed, and the key lives only for this run.
|
||||
COMPARED_SECRET = re.compile(r"^(passphrase|psk|ft_key|wpa_key|key|sae_password)$", re.I)
|
||||
RUN_KEY = secrets.token_hex(32)
|
||||
|
||||
|
||||
def secret_token(value: str) -> str:
|
||||
"""Same formula as the SQL in scrub_secrets: md5(run key || value)."""
|
||||
return "demo-" + hashlib.md5((RUN_KEY + value).encode()).hexdigest()[:16]
|
||||
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
|
||||
|
||||
# Whole tables that only hold secrets or personal delivery data.
|
||||
SECRET_TABLES = ["user_ssh_keys", "user_backup_codes", "notification_deliveries",
|
||||
"notification_mutes", "notification_channels", "trusted_networks"]
|
||||
|
||||
|
||||
async def columns(con) -> list[tuple[str, str, str]]:
|
||||
rows = await con.fetch(
|
||||
"SELECT table_name, column_name, data_type FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' ORDER BY table_name, ordinal_position")
|
||||
return [(r[0], r[1], r[2]) for r in rows
|
||||
if r[0] not in SKIP_TABLES and r[2] in TEXT_TYPES]
|
||||
|
||||
|
||||
ADDRESS_COLUMN = re.compile(r"(^|_)(ip|ips|ip_address|address|addr|host|target|source|wan|gateway|peer|value)(_|$)")
|
||||
|
||||
|
||||
QUAD = r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||||
# A dotted quad reads as an address when it is a whole JSON string value (not
|
||||
# under a version-like key) or follows a word that introduces an address.
|
||||
AS_JSON_VALUE = re.compile(rf'(?:"([^"]*)"\s*:\s*)?"({QUAD})(?:/\d{{1,2}})?"')
|
||||
AS_PROSE = re.compile(
|
||||
rf"(?i)\b(?:from|to|ip|ipv4|addr|address|host|src|dst|source|peer|wan|gateway|gw|via|at|by|nameserver|server)\W{{1,3}}({QUAD})")
|
||||
VERSIONISH = re.compile(r"(?i)version|ver$|release|build|firmware|kernel|rev")
|
||||
|
||||
|
||||
def addresses_in(value: str, whole_column: bool) -> set[str]:
|
||||
found = set()
|
||||
if whole_column:
|
||||
found.update(IPV4.findall(value))
|
||||
for key, ip in AS_JSON_VALUE.findall(value):
|
||||
if not (key and VERSIONISH.search(key)):
|
||||
found.add(ip)
|
||||
found.update(AS_PROSE.findall(value))
|
||||
return found
|
||||
|
||||
|
||||
async def collect_public(con, mapper: Mapper) -> None:
|
||||
"""Learn which public IPv4 addresses really are addresses."""
|
||||
for t, c, dt in await columns(con):
|
||||
whole = dt in ("inet", "cidr") or bool(ADDRESS_COLUMN.search(c))
|
||||
rows = await con.fetch(
|
||||
f'SELECT DISTINCT "{c}"::text AS v FROM "{t}" WHERE "{c}"::text ~ $1', QUAD)
|
||||
for r in rows:
|
||||
for ip in addresses_in(r["v"], whole):
|
||||
try:
|
||||
a = ipaddress.IPv4Address(ip)
|
||||
except ValueError:
|
||||
continue
|
||||
if a.is_global and ip not in KEEP_PUBLIC:
|
||||
mapper.known_public.add(ip)
|
||||
|
||||
|
||||
async def scrub_secrets(con, dry: bool) -> None:
|
||||
rows = await con.fetch(
|
||||
"SELECT c.table_name, c.column_name, c.is_nullable, c.data_type "
|
||||
"FROM information_schema.columns c JOIN information_schema.tables t "
|
||||
"ON t.table_name = c.table_name AND t.table_schema = c.table_schema "
|
||||
"WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE'")
|
||||
for t, c, nullable, dt in rows:
|
||||
if t in SKIP_TABLES or c in SECRET_KEEP or not SECRET_COLUMN.search(c):
|
||||
continue
|
||||
if dt not in ("text", "character varying", "jsonb", "json", "bytea"):
|
||||
continue # flags like require_password are booleans
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
|
||||
if not n:
|
||||
continue
|
||||
if COMPARED_SECRET.match(c) and dt in ("text", "character varying"):
|
||||
print(f" {t}.{c}: {n} replaced by keyed hash")
|
||||
if not dry:
|
||||
await con.execute(
|
||||
f'UPDATE "{t}" SET "{c}" = \'demo-\' || left(md5($1 || "{c}"), 16) '
|
||||
f'WHERE "{c}" IS NOT NULL AND "{c}" <> \'\'', RUN_KEY)
|
||||
continue
|
||||
value = "NULL" if nullable == "YES" else ("'{}'" if dt in ("jsonb", "json") else "''")
|
||||
if dt == "bytea" and nullable != "YES":
|
||||
value = "''::bytea"
|
||||
print(f" {t}.{c}: {n} emptied")
|
||||
if not dry:
|
||||
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
|
||||
# Settings flagged secret keep their key, lose their value.
|
||||
if await con.fetchval("SELECT to_regclass('public.settings') IS NOT NULL"):
|
||||
n = await con.fetchval("SELECT count(*) FROM settings WHERE is_secret")
|
||||
print(f" settings: {n} secret values emptied")
|
||||
if not dry:
|
||||
await con.execute("UPDATE settings SET value = '' WHERE is_secret")
|
||||
for t in SECRET_TABLES:
|
||||
if await con.fetchval("SELECT to_regclass($1) IS NOT NULL", f"public.{t}"):
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}"')
|
||||
print(f" {t}: {n} rows deleted")
|
||||
if not dry:
|
||||
await con.execute(f'DELETE FROM "{t}"')
|
||||
|
||||
|
||||
async def rewrite(con, mapper: Mapper, cfg: dict, dry: bool) -> None:
|
||||
pat = prefilter(cfg)
|
||||
by_table: dict[str, list[tuple[str, str]]] = {}
|
||||
for t, c, dt in await columns(con):
|
||||
by_table.setdefault(t, []).append((c, dt))
|
||||
for table, cols in by_table.items():
|
||||
for col, dt in cols:
|
||||
q = f'SELECT ctid, "{col}"::text AS v FROM "{table}" WHERE "{col}"::text ~ $1'
|
||||
rows = await con.fetch(q, pat)
|
||||
updates = []
|
||||
for r in rows:
|
||||
new = mapper.text(r["v"])
|
||||
if new != r["v"]:
|
||||
updates.append((new, r["ctid"]))
|
||||
if not updates:
|
||||
continue
|
||||
print(f" {table}.{col}: {len(updates)} rows")
|
||||
if dry:
|
||||
continue
|
||||
cast = {"jsonb": "::jsonb", "json": "::json", "inet": "::inet", "cidr": "::cidr",
|
||||
"macaddr": "::macaddr"}.get(dt, "")
|
||||
if dt == "ARRAY":
|
||||
udt = await con.fetchval(
|
||||
"SELECT udt_name FROM information_schema.columns "
|
||||
"WHERE table_name = $1 AND column_name = $2", table, col)
|
||||
cast = f"::{udt.lstrip('_')}[]"
|
||||
await con.executemany(
|
||||
f'UPDATE "{table}" SET "{col}" = $1{cast} WHERE ctid = $2', updates)
|
||||
|
||||
|
||||
async def reset_users(con, cfg: dict, dry: bool) -> None:
|
||||
sys.path.insert(0, str(Path(cfg["netork_src"]).expanduser()))
|
||||
from netork.core.security import hash_password # noqa: E402
|
||||
|
||||
admin = cfg.get("admin_from", "chris")
|
||||
password = cfg.get("admin_password", "netork-demo")
|
||||
users = await con.fetch("SELECT id, username FROM users ORDER BY username")
|
||||
print(f" users: {[u['username'] for u in users]}")
|
||||
if dry:
|
||||
return
|
||||
n = 0
|
||||
for u in users:
|
||||
if u["username"] == admin:
|
||||
await con.execute(
|
||||
"UPDATE users SET username = 'netork', email = $2, hashed_password = $3, "
|
||||
"totp_secret = NULL, totp_enabled = false, token_version = token_version + 1 "
|
||||
"WHERE id = $1", u["id"], f"netork@{DEMO_DOMAIN}", hash_password(password))
|
||||
else:
|
||||
n += 1
|
||||
await con.execute(
|
||||
"UPDATE users SET username = $2, email = $3, hashed_password = $4, "
|
||||
"totp_secret = NULL, totp_enabled = false, is_active = false WHERE id = $1",
|
||||
u["id"], f"operator{n}", f"operator{n}@{DEMO_DOMAIN}", hash_password(os.urandom(16).hex()))
|
||||
# TOTP secrets are gone, so a role that demands MFA would lock everyone out.
|
||||
await con.execute("UPDATE roles SET require_mfa = false")
|
||||
role = await con.fetchval("SELECT id FROM roles WHERE lower(name) IN ('administrator', 'admin') LIMIT 1")
|
||||
if role:
|
||||
await con.execute("UPDATE users SET role_id = $1, is_superuser = true WHERE username = 'netork'", role)
|
||||
print(f" admin '{admin}' is now 'netork' / '{password}'")
|
||||
|
||||
|
||||
async def leak_report(con, cfg: dict, originals: list[str]) -> int:
|
||||
# Names are matched as written (FAMILY is a VLAN, "family" a JSON key);
|
||||
# leak_terms and domains in any case.
|
||||
names = [n for n in [*cfg.get("hostnames", {}), *cfg.get("terms", {}), *cfg.get("substrings", {})]
|
||||
if len(n) >= 4]
|
||||
loose = [n for n in [*cfg.get("domains", {}), *cfg.get("leak_terms", [])] if len(n) >= 4]
|
||||
# Postgres has no inline (?i:...), so spell case-insensitivity out: [mM][aA]...
|
||||
def anycase(t: str) -> str:
|
||||
return "".join(f"[{c.lower()}{c.upper()}]" if c.isalpha() else re.escape(c) for c in t)
|
||||
parts = [re.escape(n) for n in names] + [anycase(n) for n in loose]
|
||||
if not parts:
|
||||
return 0
|
||||
pat = "|".join(parts)
|
||||
found = 0
|
||||
for t, c, _ in await columns(con):
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~ $1', pat)
|
||||
if n:
|
||||
found += n
|
||||
sample = await con.fetchval(
|
||||
f'SELECT substring("{c}"::text from $2) FROM "{t}" WHERE "{c}"::text ~ $1 LIMIT 1',
|
||||
pat, f"(.{{0,30}}(?:{pat}).{{0,30}})")
|
||||
print(f" LEAK {t}.{c}: {n} rows, e.g. …{sample}…")
|
||||
return found
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--dsn", default=os.environ.get("DEMO_DSN", DEFAULT_DSN))
|
||||
ap.add_argument("--map", type=Path, default=DEFAULT_MAP)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--report-only", action="store_true", help="only run the leak report")
|
||||
args = ap.parse_args()
|
||||
|
||||
host = re.search(r"@([^:/]+)", args.dsn)
|
||||
if not host or host.group(1) not in ("127.0.0.1", "localhost", "::1"):
|
||||
sys.exit("Refusing: this only runs against a local copy.")
|
||||
cfg = json.loads(args.map.read_text())
|
||||
mapper = Mapper(cfg)
|
||||
originals = [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
|
||||
*cfg.get("leak_terms", [])]
|
||||
|
||||
con = await asyncpg.connect(args.dsn)
|
||||
try:
|
||||
if not args.report_only:
|
||||
async with con.transaction():
|
||||
print("secrets:")
|
||||
await scrub_secrets(con, args.dry_run)
|
||||
print("users:")
|
||||
await reset_users(con, cfg, args.dry_run)
|
||||
await collect_public(con, mapper)
|
||||
print(f"public addresses seen as addresses: {len(mapper.known_public)}")
|
||||
print("rewriting:")
|
||||
await rewrite(con, mapper, cfg, args.dry_run)
|
||||
print("ipv4 /16 mapping:", json.dumps(mapper.prefix16))
|
||||
print("public addresses mapped:", len(mapper.public))
|
||||
if mapper.unmapped_public:
|
||||
top = sorted(mapper.unmapped_public.items(), key=lambda x: -x[1])[:40]
|
||||
print("left as is (versions? add real ones to public_ips in the map):")
|
||||
print(" " + ", ".join(f"{ip} ({n}x)" for ip, n in top))
|
||||
print("leak report:")
|
||||
n = await leak_report(con, cfg, originals)
|
||||
if not args.report_only and mapper.unmapped_public:
|
||||
print(f" review: {len(mapper.unmapped_public)} public-looking dotted quads left as is (listed above)")
|
||||
print(" clean" if n == 0 else f" {n} rows still match")
|
||||
finally:
|
||||
await con.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local netOrk demo instance for website screenshots.
|
||||
#
|
||||
# up.sh restore <dump> fresh demo DB from a pg_dump -Fc file, then anonymize
|
||||
# up.sh start API on :8000 and UI on :5173 (foreground, Ctrl-C stops)
|
||||
# up.sh stop stop the demo database container
|
||||
#
|
||||
# Only the API and the UI run: no Celery worker, no beat, no Redis. Nothing
|
||||
# polls, nothing reboots, nothing reaches a device. Stored credentials are
|
||||
# emptied by anonymize.py and the encryption key is a fresh random one, so
|
||||
# even a leftover value could not be decrypted.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
DEMO="${NETORK_DEMO_DIR:-$HOME/.cache/netork-demo}"
|
||||
SRC="$DEMO/src"
|
||||
VENV="${NETORK_VENV:-$HOME/dev/NetOrk/.venv}"
|
||||
VERSION="${NETORK_DEMO_VERSION:-v0.28.0}"
|
||||
NETORK_REPO="${NETORK_REPO:-$HOME/dev/NetOrk}"
|
||||
DB=netork-demo-db
|
||||
PORT=55432
|
||||
|
||||
ensure_src() {
|
||||
if [ ! -d "$SRC/netork" ]; then
|
||||
mkdir -p "$SRC"
|
||||
git -C "$NETORK_REPO" archive "$VERSION" | tar -x -C "$SRC"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_db() {
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$DB"; then
|
||||
docker start "$DB" 2>/dev/null || docker run -d --name "$DB" \
|
||||
-p 127.0.0.1:$PORT:5432 -e POSTGRES_DB=netork -e POSTGRES_USER=netork \
|
||||
-e POSTGRES_PASSWORD=demo -v netork-demo-pg:/var/lib/postgresql/data postgres:16-alpine
|
||||
until docker exec "$DB" pg_isready -U netork -q; do sleep 1; done
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
restore)
|
||||
dump="${2:?usage: up.sh restore <dump file>}"
|
||||
ensure_src; ensure_db
|
||||
docker exec "$DB" psql -U netork -d postgres -q \
|
||||
-c "DROP DATABASE IF EXISTS netork WITH (FORCE)" -c "CREATE DATABASE netork"
|
||||
docker exec -i "$DB" pg_restore -U netork -d netork --no-owner --no-privileges < "$dump" \
|
||||
|| echo "pg_restore reported errors (often only missing roles/extensions); checking ..."
|
||||
got=$(docker exec "$DB" psql -U netork -tA -c "SELECT version_num FROM alembic_version")
|
||||
want=$(cd "$SRC" && PATH="$VENV/bin:$PATH" alembic heads 2>/dev/null | awk '{print $1}')
|
||||
echo "dump schema: $got $VERSION head: $want"
|
||||
# Anonymize first: it empties every secret, so a downgrade that would
|
||||
# have to decrypt something (with a key we do not have) finds nothing.
|
||||
"$VENV/bin/python" "$HERE/anonymize.py"
|
||||
if [ "$got" != "$want" ]; then
|
||||
# The production instance runs a newer build. Walk the copy back to the
|
||||
# release with the newer code's own downgrade migrations.
|
||||
NEWER="${NETORK_NEWER_REF:-origin/main}"
|
||||
echo "migrating the copy from $got back to $want with $NEWER's migrations"
|
||||
rm -rf "$DEMO/src-newer"; mkdir -p "$DEMO/src-newer"
|
||||
git -C "$NETORK_REPO" archive "$NEWER" | tar -x -C "$DEMO/src-newer"
|
||||
# Rows the older schema cannot hold: CrowdSec blocklist alerts whose scope
|
||||
# is a list name, longer than the column they go back into.
|
||||
docker exec "$DB" psql -U netork -q -c \
|
||||
"DELETE FROM crowdsec_alerts WHERE length(source_scope) > 32" 2>/dev/null || true
|
||||
(cd "$DEMO/src-newer" && PATH="$VENV/bin:$PATH" \
|
||||
DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork" alembic downgrade "$want")
|
||||
"$VENV/bin/python" "$HERE/anonymize.py" --report-only
|
||||
fi
|
||||
;;
|
||||
start)
|
||||
ensure_src; ensure_db
|
||||
[ -d "$SRC/ui/node_modules" ] || (cd "$SRC/ui" && npm ci --no-audit --no-fund)
|
||||
export DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork"
|
||||
export ENVIRONMENT=development
|
||||
export SECRET_KEY="$(openssl rand -hex 32)"
|
||||
export CREDENTIAL_ENCRYPTION_KEY="$("$VENV/bin/python" -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
|
||||
# Nothing listens on port 1: no task can be queued, so no worker could act.
|
||||
export REDIS_URL=redis://127.0.0.1:1/0 CELERY_BROKER_URL=redis://127.0.0.1:1/0 CELERY_RESULT_BACKEND=redis://127.0.0.1:1/1
|
||||
cd "$SRC"
|
||||
"$VENV/bin/uvicorn" netork.api.main:app --host 127.0.0.1 --port 8000 &
|
||||
api=$!
|
||||
trap 'kill $api 2>/dev/null' EXIT
|
||||
cd ui && npx vite --host 127.0.0.1 --port 5173 --strictPort
|
||||
;;
|
||||
stop)
|
||||
docker stop "$DB"
|
||||
;;
|
||||
*)
|
||||
sed -n '2,12p' "$0"; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Take real screenshots of a running netOrk instance for the website.
|
||||
|
||||
Normally that instance is the local demo copy from scripts/demo (anonymized
|
||||
production data), which this script logs into on its own:
|
||||
|
||||
capture.py --list-devices # prints IDs to pick for --var
|
||||
capture.py --var ap=<id> [--only name ...]
|
||||
|
||||
Against a real instance, log in by hand and cover what must not be seen:
|
||||
|
||||
NETORK_URL=https://... capture.py --login
|
||||
NETORK_URL=https://... capture.py --mask --var ...
|
||||
|
||||
While capturing, every request to the API that is not a GET is aborted, so
|
||||
taking screenshots cannot change anything on the instance.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from shots import SHOTS
|
||||
|
||||
BASE = os.environ.get("NETORK_URL", "http://127.0.0.1:5173").rstrip("/")
|
||||
LOCAL = re.match(r"https?://(127\.0\.0\.1|localhost)[:/]", BASE + "/") is not None
|
||||
# The demo instance's admin (see scripts/demo/anonymize.py).
|
||||
USER = os.environ.get("NETORK_USER", "netork")
|
||||
PASSWORD = os.environ.get("NETORK_PASSWORD", "netork-demo")
|
||||
STATE = Path(os.environ.get(
|
||||
"NETORK_STATE", Path.home() / ".cache" / "netork-screenshots" / "state.json"))
|
||||
# One term per line: site names, customer names, domains ... never committed.
|
||||
MASK_FILE = Path(os.environ.get(
|
||||
"NETORK_MASK_FILE", Path.home() / ".config" / "netork-screenshots" / "mask.txt"))
|
||||
SITE = Path(__file__).resolve().parents[2]
|
||||
OUT = SITE / "public" / "screenshots"
|
||||
|
||||
VIEWPORT = {"width": 1600, "height": 1000}
|
||||
|
||||
# Any IPv4 address that is not RFC 1918, loopback or link-local.
|
||||
PUBLIC_IPV4 = re.compile(
|
||||
r"\b(?!10\.)(?!127\.)(?!169\.254\.)(?!192\.168\.)(?!172\.(?:1[6-9]|2\d|3[01])\.)"
|
||||
r"(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b")
|
||||
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
|
||||
|
||||
|
||||
def mask_terms() -> list[str]:
|
||||
if not MASK_FILE.exists():
|
||||
return []
|
||||
return [t.strip() for t in MASK_FILE.read_text().splitlines()
|
||||
if t.strip() and not t.startswith("#")]
|
||||
|
||||
|
||||
def login() -> None:
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
ctx = browser.new_context(ignore_https_errors=True, viewport=VIEWPORT)
|
||||
page = ctx.new_page()
|
||||
page.goto(f"{BASE}/login")
|
||||
print("Log in in the browser window (10 minutes) ...", flush=True)
|
||||
page.wait_for_function(
|
||||
"() => localStorage.getItem('token') && !location.pathname.startsWith('/login')",
|
||||
timeout=600_000)
|
||||
ctx.storage_state(path=STATE)
|
||||
STATE.chmod(0o600)
|
||||
browser.close()
|
||||
print(f"Session saved to {STATE}")
|
||||
|
||||
|
||||
def token() -> str:
|
||||
if LOCAL:
|
||||
body = urllib.parse.urlencode({"username": USER, "password": PASSWORD}).encode()
|
||||
try:
|
||||
with urllib.request.urlopen(f"{BASE}/api/v1/auth/token", body) as res:
|
||||
tok = json.load(res).get("access_token")
|
||||
if not tok:
|
||||
sys.exit(f"Login as {USER} needs MFA; the demo copy should have none (anonymize.py)")
|
||||
return tok
|
||||
except urllib.error.URLError as e:
|
||||
sys.exit(f"Login as {USER} at {BASE} failed: {e} (is scripts/demo/up.sh start running?)")
|
||||
state = json.loads(STATE.read_text())
|
||||
for origin in state.get("origins", []):
|
||||
for item in origin.get("localStorage", []):
|
||||
if item["name"] == "token":
|
||||
return item["value"]
|
||||
sys.exit("No token in the saved session; run --login first.")
|
||||
|
||||
|
||||
def list_devices() -> None:
|
||||
with sync_playwright() as p:
|
||||
req = p.request.new_context(
|
||||
base_url=BASE, ignore_https_errors=True,
|
||||
extra_http_headers={"Authorization": f"Bearer {token()}"})
|
||||
res = req.get("/api/v1/devices/")
|
||||
if not res.ok:
|
||||
sys.exit(f"{res.status}: {res.text()[:200]} (session expired? run --login)")
|
||||
for d in res.json():
|
||||
print(f"{d.get('id')} {d.get('driver') or '-':18} "
|
||||
f"{d.get('device_type') or '-':20} {d.get('hostname')}")
|
||||
|
||||
|
||||
def settle(page: Page) -> None:
|
||||
"""Wait until the page has finished loading its data."""
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=15_000)
|
||||
except Exception:
|
||||
pass # pages that poll never go fully idle
|
||||
try:
|
||||
page.wait_for_function(
|
||||
"() => !document.querySelector('.animate-spin, .animate-pulse')", timeout=15_000)
|
||||
except Exception:
|
||||
print(" still loading after 15 s, taking the shot anyway")
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def publish(png: bytes, path: Path, width: int) -> dict[str, int]:
|
||||
"""Scale the 2x capture down to its published width and store it as WebP."""
|
||||
img = Image.open(io.BytesIO(png)).convert("RGB")
|
||||
if img.width > width:
|
||||
img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS)
|
||||
img.save(path, "WEBP", quality=85, method=6)
|
||||
print(f" -> {path.name} {img.width}x{img.height}, {path.stat().st_size // 1024} KB")
|
||||
return {"width": img.width, "height": img.height}
|
||||
|
||||
|
||||
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
terms = mask_terms()
|
||||
tok = token() if LOCAL else None
|
||||
blocked: list[str] = []
|
||||
sizes_file = SITE / "src" / "data" / "screenshots.json"
|
||||
sizes: dict[str, dict[str, int]] = json.loads(sizes_file.read_text()) if sizes_file.exists() else {}
|
||||
|
||||
def guard(route):
|
||||
if route.request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
route.continue_()
|
||||
else:
|
||||
blocked.append(f"{route.request.method} {route.request.url}")
|
||||
route.abort()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
ctx = browser.new_context(
|
||||
storage_state=None if LOCAL else STATE, ignore_https_errors=True,
|
||||
viewport=VIEWPORT, device_scale_factor=2, color_scheme="dark")
|
||||
if tok:
|
||||
ctx.add_init_script(f"localStorage.setItem('token', {json.dumps(tok)})")
|
||||
ctx.route("**/api/**", guard)
|
||||
page = ctx.new_page()
|
||||
for shot in SHOTS:
|
||||
if only and shot.name not in only:
|
||||
continue
|
||||
try:
|
||||
path = shot.path.format(**variables)
|
||||
except KeyError as e:
|
||||
print(f"skip {shot.name}: needs --var {e.args[0]}=<id>")
|
||||
continue
|
||||
print(f"{shot.name}: {path}")
|
||||
vw, vh = shot.viewport or (VIEWPORT["width"], VIEWPORT["height"])
|
||||
page.set_viewport_size({"width": vw, "height": vh})
|
||||
page.goto(f"{BASE}{path}")
|
||||
if page.url.rstrip("/").endswith("/login"):
|
||||
sys.exit("Session expired; run --login again.")
|
||||
page.wait_for_selector(shot.wait_for, timeout=20_000)
|
||||
settle(page)
|
||||
# The release notes dialog after an upgrade; dismissing it only
|
||||
# writes localStorage in this throwaway browser context.
|
||||
got_it = page.get_by_role("button", name="Got it")
|
||||
if got_it.is_visible():
|
||||
got_it.click()
|
||||
page.wait_for_timeout(300)
|
||||
for sel in shot.clicks:
|
||||
page.locator(sel).first.click()
|
||||
settle(page)
|
||||
masks = [page.locator(s) for s in shot.mask]
|
||||
if mask:
|
||||
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
|
||||
masks += [page.get_by_text(t) for t in terms]
|
||||
clip = None
|
||||
if shot.clip:
|
||||
x, y, w, h = shot.clip
|
||||
clip = {"x": x, "y": y, "width": w, "height": h}
|
||||
elif shot.element:
|
||||
boxes = []
|
||||
for sel in ([shot.element] if isinstance(shot.element, str) else shot.element):
|
||||
el = page.locator(sel).first
|
||||
el.scroll_into_view_if_needed()
|
||||
b = el.bounding_box()
|
||||
if not b:
|
||||
sys.exit(f"{shot.name}: element not found: {sel}")
|
||||
boxes.append(b)
|
||||
left = min(b["x"] for b in boxes)
|
||||
top = min(b["y"] for b in boxes)
|
||||
right = max(b["x"] + b["width"] for b in boxes)
|
||||
bottom = max(b["y"] + b["height"] for b in boxes)
|
||||
box = {"x": left, "y": top, "width": right - left, "height": bottom - top}
|
||||
x0, y0 = max(0, box["x"] - shot.pad), max(0, box["y"] - shot.pad)
|
||||
clip = {"x": x0, "y": y0,
|
||||
"width": min(vw - x0, box["width"] + 2 * shot.pad),
|
||||
"height": min(vh - y0, box["height"] + 2 * shot.pad)}
|
||||
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
|
||||
mask_color="#334155", animations="disabled")
|
||||
sizes[shot.name] = publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||
browser.close()
|
||||
# The site reads these to reserve the right space for each image.
|
||||
sizes_file.write_text(json.dumps(dict(sorted(sizes.items())), indent=2) + "\n")
|
||||
if blocked:
|
||||
print("Blocked non-GET requests (nothing was sent):")
|
||||
for b in sorted(set(blocked)):
|
||||
print(f" {b}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--login", action="store_true", help="log in and save the session")
|
||||
ap.add_argument("--list-devices", action="store_true", help="print device IDs")
|
||||
ap.add_argument("--var", action="append", default=[], metavar="NAME=VALUE",
|
||||
help="fill a {placeholder} in the shot paths")
|
||||
ap.add_argument("--only", nargs="*", default=[], help="only these shot names")
|
||||
ap.add_argument("--mask", action="store_true",
|
||||
help="cover public IPs, e-mails and the mask-file terms (real instances)")
|
||||
args = ap.parse_args()
|
||||
if args.login:
|
||||
login()
|
||||
elif args.list_devices:
|
||||
list_devices()
|
||||
else:
|
||||
capture(dict(v.split("=", 1) for v in args.var), set(args.only), args.mask)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The screenshots the website uses, as data.
|
||||
|
||||
Each shot is one page of the netOrk UI. `path` may contain `{placeholders}`
|
||||
that are filled from `--var name=value` on the command line (device IDs
|
||||
differ per instance, so they are never hard-coded here). Device detail
|
||||
sections are addressed through the URL hash the UI itself writes
|
||||
(`#security/assessment`, `#config`, ...), so no clicking is needed.
|
||||
|
||||
`mask` lists extra CSS selectors to cover on top of the automatic masks
|
||||
(public IPv4 addresses, e-mail addresses, and the terms from the mask file).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Shot:
|
||||
name: str
|
||||
path: str
|
||||
# Selector that must be visible before the shot is taken.
|
||||
wait_for: str = "main"
|
||||
mask: list[str] = field(default_factory=list)
|
||||
full_page: bool = False
|
||||
# Crop to a region (x, y, width, height in CSS px of the viewport) ...
|
||||
clip: tuple[int, int, int, int] | None = None
|
||||
# ... or to one element, plus `pad` px around it. Cropping shows less of a
|
||||
# real screen; it never changes what is on it.
|
||||
element: str | list[str] | None = None # several: crop to what they cover together
|
||||
pad: int = 16
|
||||
# Viewport for this shot, (width, height) in CSS px; default in capture.py.
|
||||
viewport: tuple[int, int] | None = None
|
||||
# Width of the published WebP in pixels (captures are taken at 2x).
|
||||
width: int = 1600
|
||||
# Selectors clicked in order before the shot, first match each. Only for
|
||||
# controls that change the view (filters, tabs); the API guard in
|
||||
# capture.py aborts anything that would write.
|
||||
clicks: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# The drift comparison on a device page: the summary line ("… (42 compared)") down to
|
||||
# the end of the table. The card around it stretches to the window height.
|
||||
DRIFT_CARD = ["xpath=//*[contains(text(), 'compared)')]", "xpath=//table[.//th[contains(., 'Expected')]]"]
|
||||
|
||||
SHOTS: list[Shot] = [
|
||||
# Home hero: an access point checked against its profile, wide and short.
|
||||
Shot("drift", "/devices/{ap}#drift", wait_for="main table", element=DRIFT_CARD,
|
||||
pad=28, viewport=(1440, 900), width=2400),
|
||||
# Phones: the same finding from "Parameter" to the status badge, readable at 390px.
|
||||
Shot("drift-narrow", "/devices/{ap}#drift", wait_for="main table",
|
||||
element=["xpath=//th[contains(., 'Parameter')]", "xpath=//th[contains(., 'Actual')]",
|
||||
"xpath=//tbody//td[contains(., 'Remote Syslog')]",
|
||||
"xpath=//tbody//span[contains(., 'Incomplete')]",
|
||||
"xpath=//tbody//*[starts-with(normalize-space(text()), 'Set this field')]"],
|
||||
pad=16, viewport=(1180, 900), width=1200),
|
||||
Shot("vulnerabilities", "/vulnerabilities", clip=(256, 40, 1344, 620), width=2400),
|
||||
# Background polls drown out what people did: filter the scheduler out,
|
||||
# the way a reader would (click a source badge, then flip it to exclude).
|
||||
Shot("audit-log", "/audit-log", clip=(256, 40, 1344, 560), width=2400, clicks=[
|
||||
"tbody td >> text=scheduler",
|
||||
"button[title='Click to toggle include/exclude']",
|
||||
]),
|
||||
]
|
||||
+21
-16
@@ -1,36 +1,41 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Nav from './components/Nav'
|
||||
import Footer from './components/Footer'
|
||||
import ScrollManager from './components/ScrollManager'
|
||||
import Home from './pages/Home'
|
||||
import Features from './pages/Features'
|
||||
import Drivers from './pages/Drivers'
|
||||
import GettingStarted from './pages/GettingStarted'
|
||||
import Roadmap from './pages/Roadmap'
|
||||
import Pricing from './pages/Pricing'
|
||||
import Persona from './pages/Persona'
|
||||
import Nis2 from './pages/Nis2'
|
||||
import Plugins from './pages/Plugins'
|
||||
import Glossary from './pages/Glossary'
|
||||
import ForItDepartment from './pages/ForItDepartment'
|
||||
import ForItSupport from './pages/ForItSupport'
|
||||
import ForMsp from './pages/ForMsp'
|
||||
import GettingStarted from './pages/GettingStarted'
|
||||
import Legal from './pages/Legal'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-slate-950 flex flex-col">
|
||||
<ScrollManager />
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Nav />
|
||||
<main className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/features" element={<Features />} />
|
||||
<Route path="/drivers" element={<Drivers />} />
|
||||
<Route path="/docs/getting-started" element={<GettingStarted />} />
|
||||
<Route path="/roadmap" element={<Roadmap />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/for/it-department" element={<Persona id="itDepartment" />} />
|
||||
<Route path="/for/it-support" element={<Persona id="itSupport" />} />
|
||||
<Route path="/for/msp" element={<Persona id="msp" />} />
|
||||
<Route path="/nis2" element={<Nis2 />} />
|
||||
<Route path="/plugins" element={<Plugins />} />
|
||||
<Route path="/glossary" element={<Glossary />} />
|
||||
<Route path="/for/it-department" element={<ForItDepartment />} />
|
||||
<Route path="/for/it-support" element={<ForItSupport />} />
|
||||
<Route path="/for/msp" element={<ForMsp />} />
|
||||
<Route path="/docs/getting-started" element={<GettingStarted />} />
|
||||
<Route path="/impressum" element={<Legal page="imprint" />} />
|
||||
<Route path="/datenschutz" element={<Legal page="privacy" />} />
|
||||
|
||||
{/* Pages that were folded into the homepage. nginx.conf answers the same paths with a 301. */}
|
||||
<Route path="/features" element={<Navigate to="/#included" replace />} />
|
||||
<Route path="/drivers" element={<Navigate to="/#hardware" replace />} />
|
||||
<Route path="/roadmap" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
+63
-39
@@ -1,56 +1,80 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { CONTACT_EMAIL } from '../i18n/translations'
|
||||
import { Container } from './ui'
|
||||
import { Wordmark } from './Nav'
|
||||
|
||||
const linkCls = 'text-ink-muted transition-colors hover:text-ink'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useLang()
|
||||
const f = t.footer
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: f.product,
|
||||
links: [
|
||||
{ to: '/pricing', label: t.nav.pricing },
|
||||
{ to: '/nis2', label: t.nav.nis2 },
|
||||
{ to: '/plugins', label: t.nav.plugins },
|
||||
{ to: '/glossary', label: f.glossary },
|
||||
{ to: '/docs/getting-started', label: f.gettingStarted },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: f.forWhom,
|
||||
links: [
|
||||
{ to: '/for/it-department', label: t.nav.forItems.itDepartment },
|
||||
{ to: '/for/it-support', label: t.nav.forItems.itSupport },
|
||||
{ to: '/for/msp', label: t.nav.forItems.msp },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<footer className="bg-slate-900 border-t border-slate-800 mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-16">
|
||||
<div className="shrink-0">
|
||||
<Link to="/" className="flex items-center gap-2.5 mb-3">
|
||||
<img src="/logo.png" alt="" className="h-6 w-6" aria-hidden="true" />
|
||||
<span className="font-semibold text-slate-100 tracking-tight">
|
||||
net<span className="text-sky-400">Ork</span>
|
||||
</span>
|
||||
<footer className="mt-auto border-t border-line">
|
||||
<Container className="py-14">
|
||||
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1.5fr]">
|
||||
<div>
|
||||
<Link to="/">
|
||||
<Wordmark />
|
||||
</Link>
|
||||
<p className="text-sm text-slate-500">{t.footer.tagline}</p>
|
||||
<p className="mt-3 text-sm text-ink-muted">{f.tagline}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-10 flex-1 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-400 mb-3">{t.footer.links}</p>
|
||||
<ul className="space-y-2">
|
||||
<li><Link to="/features" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.features}</Link></li>
|
||||
<li><Link to="/drivers" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.drivers}</Link></li>
|
||||
<li><Link to="/plugins" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.plugins}</Link></li>
|
||||
<li><Link to="/roadmap" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.roadmap}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-400 mb-3">{t.footer.resources}</p>
|
||||
<ul className="space-y-2">
|
||||
<li><Link to="/docs/getting-started" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.gettingStarted}</Link></li>
|
||||
<li><Link to="/docs/architecture" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.architecture}</Link></li>
|
||||
<li><span className="text-slate-600">{t.footer.changelog}</span></li>
|
||||
<li><Link to="/nis2" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.nis2}</Link></li>
|
||||
<li><Link to="/glossary" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.glossary}</Link></li>
|
||||
<li><Link to="/for/it-department" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forItDepartment}</Link></li>
|
||||
<li><Link to="/for/it-support" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forItSupport}</Link></li>
|
||||
<li><Link to="/for/msp" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forMsp}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-400 mb-3">{t.footer.legal}</p>
|
||||
<ul className="space-y-2">
|
||||
<li><span className="text-slate-600">{t.footer.mit}</span></li>
|
||||
<li><span className="text-slate-600">{t.footer.privacy}</span></li>
|
||||
{columns.map((col) => (
|
||||
<div key={col.title} className="text-sm">
|
||||
<p className="font-medium text-ink">{col.title}</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{col.links.map((l) => (
|
||||
<li key={l.to}>
|
||||
<Link to={l.to} className={linkCls}>
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-ink">{f.contact}</p>
|
||||
<p className="mt-3">
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className={linkCls}>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
<p className="mt-3 text-ink-muted">{f.noTracking}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 flex flex-wrap gap-x-6 gap-y-2 border-t border-line pt-6 text-sm text-ink-faint">
|
||||
<span>© 2026 netOrk</span>
|
||||
<Link to="/impressum" className="transition-colors hover:text-ink">
|
||||
{f.imprint}
|
||||
</Link>
|
||||
<Link to="/datenschutz" className="transition-colors hover:text-ink">
|
||||
{f.privacy}
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,16 +45,17 @@ export default function GlossaryMark({ id, children }: { id: string; children: R
|
||||
ref={anchorRef}
|
||||
to={`/glossary#${entry.id}`}
|
||||
onMouseEnter={handleEnter}
|
||||
className="group relative border-b border-dotted border-sky-500/60 hover:border-sky-400 hover:text-sky-300 transition-colors"
|
||||
className="group relative underline decoration-ink-faint decoration-dotted underline-offset-4 transition-colors hover:text-accent hover:decoration-accent"
|
||||
>
|
||||
{children}
|
||||
{/* display:none while hidden: an invisible box would still widen the page on phones */}
|
||||
<span
|
||||
className={`pointer-events-none absolute bottom-full z-20 mb-2 w-64 rounded-lg
|
||||
border border-slate-700 bg-slate-900 p-3 text-left text-xs font-normal normal-case
|
||||
text-slate-400 opacity-0 shadow-2xl transition-opacity group-hover:opacity-100
|
||||
className={`pointer-events-none absolute bottom-full z-20 mb-2 hidden w-64 rounded-lg
|
||||
bg-ink p-3 text-left text-xs font-normal normal-case leading-relaxed tracking-normal
|
||||
text-paper/75 no-underline shadow-lg group-hover:block group-focus-visible:block
|
||||
${PLACEMENT_CLASS[placement]}`}
|
||||
>
|
||||
<span className="mb-1 block font-semibold text-slate-200">{name}</span>
|
||||
<span className="mb-1 block font-semibold text-paper">{name}</span>
|
||||
{entry.definition[lang]}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
+92
-96
@@ -1,30 +1,29 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline'
|
||||
import { Bars3Icon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import type { Lang } from '../i18n/translations'
|
||||
import { ButtonLink, Container } from './ui'
|
||||
|
||||
const dropdownItemCls =
|
||||
'block px-4 py-2 text-sm text-slate-400 hover:text-slate-100 hover:bg-slate-800 transition-colors'
|
||||
export function Wordmark() {
|
||||
return (
|
||||
<span className="text-[1.0625rem] font-semibold tracking-tight text-ink">
|
||||
net<span className="text-accent">Ork</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function NavDropdown({
|
||||
label,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const linkCls = (active: boolean) =>
|
||||
`text-sm transition-colors ${active ? 'text-ink' : 'text-ink-muted hover:text-ink'}`
|
||||
|
||||
function NavDropdown({ label, active, children }: { label: string; active: boolean; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
@@ -38,15 +37,14 @@ function NavDropdown({
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 text-sm transition-colors ${
|
||||
active ? 'text-slate-100' : 'text-slate-400 hover:text-slate-100'
|
||||
}`}
|
||||
aria-expanded={open}
|
||||
className={`flex items-center gap-1 ${linkCls(active)}`}
|
||||
>
|
||||
{label}
|
||||
<ChevronDownIcon className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full mt-2 left-0 w-52 rounded-lg border border-slate-800 bg-slate-900 py-1 shadow-xl">
|
||||
<div className="absolute left-0 top-full mt-3 w-56 rounded-lg border border-line bg-paper py-1.5 shadow-lg">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -54,89 +52,88 @@ function NavDropdown({
|
||||
)
|
||||
}
|
||||
|
||||
const dropdownItemCls = 'block px-4 py-2 text-sm text-ink-soft transition-colors hover:bg-paper-2 hover:text-ink'
|
||||
const mobileItemCls = 'block rounded-md px-3 py-2 text-ink-soft transition-colors hover:bg-paper-2 hover:text-ink'
|
||||
const mobileHeadingCls = 'px-3 pb-1 pt-4 text-eyebrow font-semibold uppercase text-ink-faint'
|
||||
|
||||
export default function Nav() {
|
||||
const location = useLocation()
|
||||
const { pathname } = useLocation()
|
||||
const { lang, setLang, t } = useLang()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const linkCls = (path: string) =>
|
||||
`text-sm transition-colors ${
|
||||
location.pathname === path
|
||||
? 'text-slate-100'
|
||||
: 'text-slate-400 hover:text-slate-100'
|
||||
}`
|
||||
useEffect(() => {
|
||||
setMenuOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
const isDocsActive =
|
||||
location.pathname.startsWith('/docs') || location.pathname === '/nis2' || location.pathname === '/glossary'
|
||||
const isForActive = location.pathname.startsWith('/for/') || location.pathname === '/nis2'
|
||||
const personas = [
|
||||
{ to: '/for/it-department', label: t.nav.forItems.itDepartment },
|
||||
{ to: '/for/it-support', label: t.nav.forItems.itSupport },
|
||||
{ to: '/for/msp', label: t.nav.forItems.msp },
|
||||
]
|
||||
const links = [
|
||||
{ to: '/nis2', label: t.nav.nis2 },
|
||||
{ to: '/plugins', label: t.nav.plugins },
|
||||
{ to: '/pricing', label: t.nav.pricing },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-10 bg-slate-900/80 backdrop-blur border-b border-slate-800">
|
||||
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center gap-8">
|
||||
<Link to="/" className="flex items-center gap-2.5 mr-2">
|
||||
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
|
||||
<span className="font-semibold text-slate-100 tracking-tight">
|
||||
net<span className="text-sky-400">Ork</span>
|
||||
</span>
|
||||
<nav className="sticky top-0 z-30 border-b border-line bg-paper/85 backdrop-blur">
|
||||
<Container className="flex h-14 items-center gap-8">
|
||||
<Link to="/" aria-label="netOrk" className="mr-2">
|
||||
<Wordmark />
|
||||
</Link>
|
||||
|
||||
<Link to="/features" className={linkCls('/features')}>
|
||||
{t.nav.features}
|
||||
</Link>
|
||||
|
||||
<Link to="/drivers" className={linkCls('/drivers')}>
|
||||
{t.nav.drivers}
|
||||
</Link>
|
||||
|
||||
<NavDropdown label={t.nav.docs} active={isDocsActive}>
|
||||
<Link to="/docs/getting-started" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.gettingStarted}
|
||||
</Link>
|
||||
<Link to="/docs/architecture" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.architecture}
|
||||
</Link>
|
||||
<div className="my-1 border-t border-slate-800" />
|
||||
<Link to="/nis2" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.nis2}
|
||||
</Link>
|
||||
<Link to="/glossary" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.glossary}
|
||||
<div className="hidden items-center gap-7 lg:flex">
|
||||
<NavDropdown label={t.nav.forWhom} active={pathname.startsWith('/for/')}>
|
||||
{personas.map((p) => (
|
||||
<Link key={p.to} to={p.to} className={dropdownItemCls}>
|
||||
{p.label}
|
||||
</Link>
|
||||
))}
|
||||
</NavDropdown>
|
||||
|
||||
<NavDropdown label={t.nav.forWhom} active={isForActive}>
|
||||
<Link to="/for/it-department" className={dropdownItemCls}>
|
||||
{t.nav.forItems.itDepartment}
|
||||
</Link>
|
||||
<Link to="/for/it-support" className={dropdownItemCls}>
|
||||
{t.nav.forItems.itSupport}
|
||||
</Link>
|
||||
<Link to="/for/msp" className={dropdownItemCls}>
|
||||
{t.nav.forItems.msp}
|
||||
</Link>
|
||||
<div className="my-1 border-t border-slate-800" />
|
||||
<Link to="/nis2" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.nis2}
|
||||
</Link>
|
||||
</NavDropdown>
|
||||
|
||||
<Link to="/plugins" className={linkCls('/plugins')}>
|
||||
{t.nav.plugins}
|
||||
</Link>
|
||||
|
||||
<Link to="/roadmap" className={linkCls('/roadmap')}>
|
||||
{t.nav.roadmap}
|
||||
{links.map((l) => (
|
||||
<Link key={l.to} to={l.to} className={linkCls(pathname === l.to)}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<LangSwitch lang={lang} setLang={setLang} />
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
<ButtonLink to="/pricing" className="hidden h-9 px-4 sm:inline-flex">
|
||||
{t.nav.buy}
|
||||
</ButtonLink>
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={t.nav.menu}
|
||||
className="-mr-1 p-1 text-ink-muted transition-colors hover:text-ink lg:hidden"
|
||||
>
|
||||
{t.nav.getStarted}
|
||||
{menuOpen ? <XMarkIcon className="h-6 w-6" /> : <Bars3Icon className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="max-h-[calc(100vh-3.5rem)] overflow-y-auto border-t border-line bg-paper lg:hidden">
|
||||
<Container className="py-3">
|
||||
{links.map((l) => (
|
||||
<Link key={l.to} to={l.to} className={mobileItemCls}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<p className={mobileHeadingCls}>{t.nav.forWhom}</p>
|
||||
{personas.map((p) => (
|
||||
<Link key={p.to} to={p.to} className={mobileItemCls}>
|
||||
{p.label}
|
||||
</Link>
|
||||
))}
|
||||
<ButtonLink to="/pricing" className="mt-4 w-full sm:hidden">
|
||||
{t.nav.buy}
|
||||
</ButtonLink>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -144,19 +141,18 @@ export default function Nav() {
|
||||
function LangSwitch({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
{(['de', 'en'] as const).map((l, i) => (
|
||||
<span key={l} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-line-strong">/</span>}
|
||||
<button
|
||||
onClick={() => setLang('de')}
|
||||
className={`transition-colors ${lang === 'de' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
onClick={() => setLang(l)}
|
||||
aria-pressed={lang === l}
|
||||
className={`uppercase transition-colors ${lang === l ? 'text-ink' : 'text-ink-faint hover:text-ink-soft'}`}
|
||||
>
|
||||
DE
|
||||
</button>
|
||||
<span className="text-slate-700">/</span>
|
||||
<button
|
||||
onClick={() => setLang('en')}
|
||||
className={`transition-colors ${lang === 'en' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
EN
|
||||
{l}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,39 @@ export const GLOSSARY: GlossaryEntry[] = [
|
||||
},
|
||||
match: ['MFA'],
|
||||
},
|
||||
{
|
||||
id: 'kea',
|
||||
category: 'networking',
|
||||
display: 'Kea',
|
||||
fullName: { en: 'ISC Kea DHCP', de: 'ISC Kea DHCP' },
|
||||
definition: {
|
||||
en: 'The DHCP server from ISC that OPNsense uses to hand out addresses; netOrk manages its subnets and reservations.',
|
||||
de: 'Der DHCP-Server von ISC, mit dem OPNsense Adressen vergibt; netOrk verwaltet seine Subnetze und Reservierungen.',
|
||||
},
|
||||
match: ['Kea'],
|
||||
},
|
||||
{
|
||||
id: 'winrm',
|
||||
category: 'networking',
|
||||
display: 'WinRM',
|
||||
fullName: { en: 'Windows Remote Management', de: 'Windows Remote Management' },
|
||||
definition: {
|
||||
en: 'Microsoft\'s remote management protocol for Windows hosts, the Windows counterpart to SSH for automation.',
|
||||
de: 'Microsofts Protokoll zur Fernverwaltung von Windows-Hosts, das Windows-Gegenstück zu SSH für Automatisierung.',
|
||||
},
|
||||
match: ['WinRM'],
|
||||
},
|
||||
{
|
||||
id: 'lapi',
|
||||
category: 'security',
|
||||
display: 'LAPI',
|
||||
fullName: { en: 'CrowdSec Local API', de: 'CrowdSec Local API' },
|
||||
definition: {
|
||||
en: 'The CrowdSec service that collects what its agents detect and holds the resulting ban decisions for one site or host.',
|
||||
de: 'Der CrowdSec-Dienst, der sammelt, was seine Agenten erkennen, und die daraus folgenden Sperrentscheidungen für einen Standort oder Host hält.',
|
||||
},
|
||||
match: ['LAPI'],
|
||||
},
|
||||
{
|
||||
id: 'nvd',
|
||||
category: 'security',
|
||||
|
||||
+510
-776
File diff suppressed because it is too large
Load Diff
+30
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Cap = '✓' | '—'
|
||||
|
||||
interface Driver {
|
||||
name: string
|
||||
deviceType: string
|
||||
facts: Cap
|
||||
interfaces: Cap
|
||||
lldp: Cap
|
||||
vlans: Cap
|
||||
ssids: Cap
|
||||
health: Cap
|
||||
docker: Cap
|
||||
reboot: Cap
|
||||
configPush: Cap
|
||||
status: 'stable' | 'beta'
|
||||
}
|
||||
|
||||
const DRIVERS: Driver[] = [
|
||||
{ name: 'fritzbox', deviceType: 'AVM Fritz!Box routers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
|
||||
{ name: 'procurve', deviceType: 'HP ProCurve / Aruba switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
|
||||
{ name: 'linux', deviceType: 'Generic Linux servers', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
|
||||
{ name: 'netgear', deviceType: 'Netgear switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'openmediavault', deviceType: 'OpenMediaVault NAS', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
|
||||
{ name: 'openwrt', deviceType: 'OpenWRT access points', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '✓', health: '✓', docker: '—', reboot: '✓', configPush: '✓', status: 'stable' },
|
||||
{ name: 'opnsense', deviceType: 'OPNsense firewalls', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
|
||||
{ name: 'proxmox', deviceType: 'Proxmox VE hypervisors', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
|
||||
{ name: 'sonos', deviceType: 'Sonos speakers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'tplink_jetstream', deviceType: 'TP-Link Jetstream switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
|
||||
{ name: 'zyxel', deviceType: 'Zyxel switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'beta' },
|
||||
]
|
||||
|
||||
const NAPALM_BUILTIN = ['Arista EOS', 'Cisco IOS', 'Cisco IOS-XE', 'Cisco NX-OS', 'Juniper JunOS']
|
||||
|
||||
const CAPS: { key: keyof Driver; label: string }[] = [
|
||||
{ key: 'facts', label: 'Facts' },
|
||||
{ key: 'interfaces', label: 'Ifaces' },
|
||||
{ key: 'lldp', label: 'LLDP' },
|
||||
{ key: 'vlans', label: 'VLANs' },
|
||||
{ key: 'ssids', label: 'SSIDs' },
|
||||
{ key: 'health', label: 'Health' },
|
||||
{ key: 'docker', label: 'Docker' },
|
||||
{ key: 'reboot', label: 'Reboot' },
|
||||
{ key: 'configPush', label: 'Config push' },
|
||||
]
|
||||
|
||||
function StatusBadge({ status }: { status: 'stable' | 'beta' }) {
|
||||
return (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
status === 'stable'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Drivers() {
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.drivers.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">{linkify(t.drivers.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-16 overflow-x-auto">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4">{t.drivers.customHeading}</h2>
|
||||
<div className="rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900">
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Driver</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Device type</th>
|
||||
{CAPS.map((c) => (
|
||||
<th key={c.key} className="px-3 py-3 text-center font-medium text-slate-500 whitespace-nowrap">
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DRIVERS.map((d) => (
|
||||
<tr key={d.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-sky-400 whitespace-nowrap">{d.name}</td>
|
||||
<td className="px-4 py-3 text-slate-300 whitespace-nowrap">{d.deviceType}</td>
|
||||
{CAPS.map((c) => (
|
||||
<td key={c.key} className="px-3 py-3 text-center">
|
||||
<span className={d[c.key] === '✓' ? 'text-green-400' : 'text-slate-700'}>
|
||||
{d[c.key]}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-3"><StatusBadge status={d.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4">{t.drivers.napalmHeading}</h2>
|
||||
<p className="text-sm text-slate-400 mb-6">{linkify(t.drivers.napalmSub)}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{NAPALM_BUILTIN.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-slate-700 bg-slate-900 text-xs font-medium text-slate-300"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
function Section({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4 pb-2 border-b border-slate-800">
|
||||
{title}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm text-slate-400 leading-relaxed">
|
||||
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||
{linkify(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.features.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">{t.features.sub}</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
{t.features.sections.map((s) => (
|
||||
<Section key={s.title} title={s.title} items={s.items} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ServerStackIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string; drivers: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps', drivers: 'Runs on what you already have' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft', drivers: 'Läuft auf dem, was ihr schon habt' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'A different admin UI per vendor', body: 'OPNsense here, HP ProCurve there, a separate tab for OpenWRT and Proxmox. Every task starts with figuring out which UI to open.' },
|
||||
{ title: 'No single view of what\'s running where', body: 'Vendor, model, firmware, and status per device live in nobody\'s head — or a spreadsheet that\'s already out of date.' },
|
||||
{ title: 'Config changes with no audit trail', body: 'Someone tweaks a firewall rule directly on the device. Nobody documents it. It breaks something three weeks later.' },
|
||||
{ title: 'Manual SSH just to check a VLAN', body: 'Verifying interface status or VLAN membership means logging into the device directly, every time.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Für jeden Hersteller eine andere Oberfläche', body: 'OPNsense hier, HP ProCurve dort, ein eigener Tab für OpenWRT und Proxmox. Jede Aufgabe beginnt damit, herauszufinden, welche UI überhaupt zuständig ist.' },
|
||||
{ title: 'Keine einzige Ansicht, was wo läuft', body: 'Hersteller, Modell, Firmware und Status pro Gerät stehen in niemandes Kopf — oder in einer Tabelle, die längst veraltet ist.' },
|
||||
{ title: 'Konfigurationsänderungen ohne Audit-Trail', body: 'Jemand ändert eine Firewall-Regel direkt am Gerät. Niemand dokumentiert es. Drei Wochen später bricht etwas.' },
|
||||
{ title: 'Manuelles SSH nur um ein VLAN zu prüfen', body: 'Um Interface-Status oder VLAN-Zugehörigkeit zu verifizieren, muss man sich jedes Mal direkt am Gerät einloggen.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Device Management', body: 'CRUD for every device with credential profiles and SSH key management. Vendor, model, and OS auto-populate from NAPALM get_facts() — no manual data entry.' },
|
||||
{ title: 'Config drift detection & one-click fix', body: 'Every poll compares device state against the desired state in netOrk. Drifted devices get a warning; fixing it streams live SSH output straight to the browser.' },
|
||||
{ title: 'Git-backed config history', body: 'Every poll snapshots the config into a local Git repository — full history, side-by-side diffs between any two points in time, and one-click restore for OPNsense.' },
|
||||
{ title: 'Ansible automation', body: '11 built-in roles (base, docker, adguard, wireguard, fail2ban, and more) with automatic dependency resolution. Write your own roles too, with full run history per device.' },
|
||||
{ title: 'VM Provisioning', body: 'Cloud-Init VMs on Proxmox straight from the hypervisor\'s VMs tab — pick an image, a VLAN, and an IP, and netOrk handles the DHCP reservation and Device linking.' },
|
||||
{ title: 'Dashboards', body: 'Configurable, shareable dashboards built from 13 widgets on a WYSIWYG grid — replace the fixed layout with the view your team actually needs.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Geräteverwaltung', body: 'CRUD für jedes Gerät mit Credential-Profilen und SSH-Schlüsselverwaltung. Hersteller, Modell und OS werden automatisch über NAPALM get_facts() befüllt — keine manuelle Eingabe.' },
|
||||
{ title: 'Konfigurationsdrift-Erkennung & Ein-Klick-Fix', body: 'Bei jedem Poll wird der Gerätezustand mit dem Sollzustand in netOrk verglichen. Abweichende Geräte erhalten eine Warnung; der Fix streamt Live-SSH-Output direkt in den Browser.' },
|
||||
{ title: 'Git-basierte Konfigurationshistorie', body: 'Bei jedem Poll wird die Konfiguration in ein lokales Git-Repository gesnapshottet — vollständige Historie, Side-by-Side-Diffs zwischen beliebigen Zeitpunkten und Ein-Klick-Restore für OPNsense.' },
|
||||
{ title: 'Ansible-Automatisierung', body: '11 eingebaute Rollen (base, docker, adguard, wireguard, fail2ban und mehr) mit automatischer Abhängigkeitsauflösung. Eigene Rollen schreiben inklusive vollständiger Lauf-Historie pro Gerät.' },
|
||||
{ title: 'VM-Provisioning', body: 'Cloud-Init-VMs auf Proxmox direkt aus dem VMs-Tab des Hypervisors — Image, VLAN und IP auswählen, netOrk übernimmt DHCP-Reservierung und Geräteverknüpfung.' },
|
||||
{ title: 'Dashboards', body: 'Konfigurierbare, teilbare Dashboards aus 13 Widgets auf einem WYSIWYG-Grid — statt festem Layout die Ansicht, die euer Team wirklich braucht.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForItDepartment() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<ServerStackIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItDepartment.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forItDepartment.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4 pb-2 border-b border-slate-800">
|
||||
{labels.drivers}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-4">
|
||||
{lang === 'en'
|
||||
? 'Custom NAPALM drivers for 11 device types, plus every built-in NAPALM driver.'
|
||||
: 'Eigene NAPALM-Treiber für 11 Gerätetypen, plus alle integrierten NAPALM-Treiber.'}
|
||||
</p>
|
||||
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{lang === 'en' ? 'Full driver reference →' : 'Vollständige Treiberreferenz →'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forItDepartment.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forItDepartment.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forItDepartment.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { LifebuoyIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Is this device actually up right now?', body: 'Checking status means opening the vendor UI for that one device — or pinging it yourself and hoping.' },
|
||||
{ title: 'The same manual reboot, over and over', body: 'A flaky AP needs a power cycle every few weeks, and someone has to remember to do it and be there for it.' },
|
||||
{ title: '"Who changed this?" — with no record', body: 'A setting is different than it was yesterday. Nobody can say who touched it, when, or why.' },
|
||||
{ title: 'Full admin access just to fix one ticket', body: 'Resolving a single warning shouldn\'t require the keys to the whole network configuration.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Läuft das Gerät gerade wirklich?', body: 'Status prüfen heißt: die Hersteller-UI für genau dieses Gerät öffnen — oder selbst pingen und hoffen.' },
|
||||
{ title: 'Derselbe manuelle Reboot, immer wieder', body: 'Ein zickiger AP braucht alle paar Wochen einen Power-Cycle — und jemand muss daran denken und dafür vor Ort sein.' },
|
||||
{ title: '„Wer hat das geändert?" — ohne Protokoll', body: 'Eine Einstellung ist anders als gestern. Niemand kann sagen, wer sie wann und warum geändert hat.' },
|
||||
{ title: 'Voller Admin-Zugriff nur für ein Ticket', body: 'Eine einzelne Warnung zu beheben sollte nicht den Schlüssel zur gesamten Netzwerkkonfiguration erfordern.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Status at a glance', body: 'Per-device warning system with severity levels (error / warning / info); a dashboard warnings widget surfaces every open issue across sites without hunting per device.' },
|
||||
{ title: 'One-click Ack', body: 'Clears a warning immediately and writes an audit log entry. Config-change warnings accept the current state as the new baseline in the same click.' },
|
||||
{ title: 'Wake-on-LAN', body: 'Saved WOL targets per device, sent via the firewall\'s driver. On-demand "Wake now" or a recurring schedule; save a seen host as a target directly from the DHCP/ARP tabs.' },
|
||||
{ title: 'Scheduled reboots & updates', body: 'Scheduled reboots for OpenWRT APs with per-site concurrency locking, and package updates scheduled or applied with one click.' },
|
||||
{ title: 'Full audit log, filterable', body: 'Every action — who, when, what — filterable by date range, user, action, or resource, exportable to CSV or PDF.' },
|
||||
{ title: 'Roles scoped to the job', body: 'Viewer/operator roles, or a custom permission set, hand out exactly the access support work needs — without granting engineer-level config rights.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Status auf einen Blick', body: 'Gerätespezifisches Warnsystem mit Schweregraden (Fehler / Warnung / Info); ein Dashboard-Warnungs-Widget zeigt jedes offene Problem standortübergreifend, ohne pro Gerät zu suchen.' },
|
||||
{ title: 'Ein-Klick-Ack', body: 'Löscht eine Warnung sofort und protokolliert die Aktion im Audit-Log. Config-Change-Warnungen akzeptieren im selben Klick den aktuellen Zustand als neue Baseline.' },
|
||||
{ title: 'Wake-on-LAN', body: 'Gespeicherte WOL-Ziele pro Gerät, gesendet über den Treiber der Firewall. Ein-Klick-„Jetzt wecken" oder wiederkehrender Zeitplan; ein gesehener Host lässt sich direkt aus den DHCP-/ARP-Tabs als Ziel speichern.' },
|
||||
{ title: 'Geplante Reboots & Updates', body: 'Geplante Neustarts für OpenWRT-APs mit standortbezogener Concurrency-Sperre, Paket-Updates geplant oder per Ein-Klick angewendet.' },
|
||||
{ title: 'Vollständiges, filterbares Audit-Log', body: 'Jede Aktion — wer, wann, was — filterbar nach Datumsbereich, Benutzer, Aktion oder Ressource, exportierbar als CSV oder PDF.' },
|
||||
{ title: 'Rollen passend zur Aufgabe', body: 'Betrachter-/Operator-Rollen oder ein eigener Berechtigungssatz geben genau den Zugriff, den Support-Arbeit braucht — ohne Engineer-Rechte für die Konfiguration.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForItSupport() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<LifebuoyIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItSupport.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forItSupport.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forItSupport.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forItSupport.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forItSupport.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Client sites you can\'t reach directly', body: 'Firewalled or disconnected networks mean juggling a VPN per client just to check a device.' },
|
||||
{ title: 'No unified view across clients', body: 'Each client\'s network lives in its own silo — there\'s no single dashboard across every site you manage.' },
|
||||
{ title: 'Proving what you did, and when', body: 'A client asks what changed after an incident, and there\'s no clean record to hand over — just memory and Slack messages.' },
|
||||
{ title: 'Client data in someone else\'s cloud', body: 'SaaS network-management tools mean client infrastructure data sits on a vendor\'s servers you don\'t control.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Kundenstandorte, die ihr nicht direkt erreicht', body: 'Firewalled oder getrennte Netzwerke bedeuten VPN-Jonglieren pro Kunde, nur um ein Gerät zu prüfen.' },
|
||||
{ title: 'Keine einheitliche Sicht über alle Kunden', body: 'Jedes Kundennetzwerk lebt in seiner eigenen Silo — kein einziges Dashboard über alle betreuten Standorte.' },
|
||||
{ title: 'Belegen, was ihr wann gemacht habt', body: 'Ein Kunde fragt nach einem Vorfall, was sich geändert hat — und es gibt kein sauberes Protokoll, nur Erinnerung und Slack-Nachrichten.' },
|
||||
{ title: 'Kundendaten in fremder Cloud', body: 'SaaS-Netzwerk-Tools bedeuten, dass die Infrastrukturdaten eurer Kunden auf Servern eines Anbieters liegen, den ihr nicht kontrolliert.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Satellite Deployments', body: 'A lightweight Docker agent deployed at a client site netOrk can\'t reach directly — polls devices locally and syncs results back to Central over HTTPS. Deployed in one flow via VM Provisioning.' },
|
||||
{ title: 'Central auto-routes around unreachable sites', body: 'Once a satellite is online and heartbeating, Central automatically skips direct polling for that site — no manual toggling per client.' },
|
||||
{ title: 'Actions work the same everywhere', body: 'Scheduled/on-demand reboots, the SNMP auto-fix flow, and discovery jobs all route through the same command channel, direct or behind a satellite. SNMP health-metric polling and WebSSH still need direct reach today.' },
|
||||
{ title: 'A real audit trail to hand a client', body: 'Every orchestration action is logged — who, what, when — filterable and exportable to CSV or PDF. Evidence, not a verbal assurance.' },
|
||||
{ title: 'Roles scoped per technician', body: 'Custom roles control exactly what each technician can do, from read-only visibility to full config access, per engagement.' },
|
||||
{ title: 'Self-hosted, no per-seat SaaS', body: 'Docker Compose deployment, no telemetry, no cloud dependency — runs on your infrastructure or a client\'s, not a vendor\'s.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Satellite-Deployments', body: 'Ein leichtgewichtiger Docker-Agent an einem Kundenstandort, den netOrk nicht direkt erreicht — pollt Geräte lokal und synct Ergebnisse per HTTPS zurück an Central. In einem Ablauf per VM-Provisioning deployt.' },
|
||||
{ title: 'Central umgeht nicht erreichbare Standorte automatisch', body: 'Sobald ein Satellite online ist und heartbeatet, überspringt Central automatisch das direkte Polling für diesen Standort — kein manuelles Umschalten pro Kunde.' },
|
||||
{ title: 'Aktionen funktionieren überall gleich', body: 'Geplante/On-Demand-Reboots, der SNMP-Auto-Fix und Discovery-Jobs laufen über denselben Command-Kanal, egal ob direkt oder hinter einem Satellite. SNMP-Health-Metrik-Polling und WebSSH brauchen heute noch direkten Zugriff.' },
|
||||
{ title: 'Ein echter Audit-Trail für den Kunden', body: 'Jede Orchestrierungsaktion wird protokolliert — wer, was, wann — filterbar und exportierbar als CSV oder PDF. Beleg statt mündlicher Zusicherung.' },
|
||||
{ title: 'Rollen pro Techniker', body: 'Benutzerdefinierte Rollen legen genau fest, was jeder Techniker darf — von reinem Lesezugriff bis vollem Konfigurationszugriff, je nach Einsatz.' },
|
||||
{ title: 'Self-hosted, kein Pro-Seat-SaaS', body: 'Docker-Compose-Deployment, keine Telemetrie, keine Cloud-Abhängigkeit — läuft auf eurer Infrastruktur oder der eines Kunden, nicht bei einem Anbieter.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForMsp() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<BuildingOffice2Icon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forMsp.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forMsp.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forMsp.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forMsp.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forMsp.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,24 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { EnvelopeIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { CONTACT_EMAIL } from '../i18n/translations'
|
||||
import { ButtonLink, PageHeader } from '../components/ui'
|
||||
|
||||
export default function GettingStarted() {
|
||||
const { t } = useLang()
|
||||
const g = t.gettingStarted
|
||||
usePageTitle(t.meta.gettingStarted)
|
||||
|
||||
return (
|
||||
<div className="py-24 md:py-40">
|
||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||
<p className="text-sm font-medium text-sky-400 mb-4 font-mono">{t.gettingStarted.label}</p>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 leading-tight mb-6 whitespace-pre-line">
|
||||
{t.gettingStarted.heading}
|
||||
</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed mb-10">
|
||||
{t.gettingStarted.sub}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:christian@manivong.de"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
>
|
||||
<EnvelopeIcon className="h-4 w-4" />
|
||||
{t.gettingStarted.cta}
|
||||
</a>
|
||||
<div className="mt-12 border-t border-slate-800 pt-8">
|
||||
<p className="text-sm text-slate-500 mb-4">{t.gettingStarted.bottomText}</p>
|
||||
<div className="flex gap-6 justify-center">
|
||||
<Link to="/features" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{t.gettingStarted.bottomFeatures}
|
||||
</Link>
|
||||
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{t.gettingStarted.bottomDrivers}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-24">
|
||||
<PageHeader eyebrow={g.eyebrow} title={g.title} lead={g.lead}>
|
||||
<ButtonLink to={`mailto:${CONTACT_EMAIL}`}>{g.cta}</ButtonLink>
|
||||
<ButtonLink to="/#how" variant="secondary">
|
||||
{g.how}
|
||||
</ButtonLink>
|
||||
<ButtonLink to="/pricing" variant="secondary">
|
||||
{g.pricing}
|
||||
</ButtonLink>
|
||||
</PageHeader>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+41
-37
@@ -1,56 +1,60 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { GLOSSARY, CATEGORY_ORDER, CATEGORY_LABELS } from '../glossary/terms'
|
||||
import { Container, PageHeader } from '../components/ui'
|
||||
|
||||
export default function Glossary() {
|
||||
const { lang, t } = useLang()
|
||||
const location = useLocation()
|
||||
usePageTitle(t.meta.glossary)
|
||||
|
||||
useEffect(() => {
|
||||
const id = location.hash.slice(1)
|
||||
if (!id) return
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, [location.hash])
|
||||
const categories = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
entries: GLOSSARY.filter((e) => e.category === category).sort((a, b) => a.display.localeCompare(b.display)),
|
||||
})).filter((c) => c.entries.length > 0)
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.glossary.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{t.glossary.sub}</p>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader title={t.glossary.title} lead={t.glossary.lead} />
|
||||
|
||||
<div className="space-y-14">
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const entries = GLOSSARY.filter((e) => e.category === category).sort((a, b) =>
|
||||
a.display.localeCompare(b.display),
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
<Container className="pb-24">
|
||||
<div className="grid gap-10 lg:grid-cols-12 lg:gap-x-8">
|
||||
{/* Category index, sticky beside the list from lg up. */}
|
||||
<nav className="lg:col-span-3">
|
||||
<ul className="flex flex-wrap gap-x-5 gap-y-2 text-sm lg:sticky lg:top-24 lg:flex-col">
|
||||
{categories.map(({ category }) => (
|
||||
<li key={category}>
|
||||
<a href={`#cat-${category}`} className="text-ink-muted transition-colors hover:text-ink">
|
||||
{CATEGORY_LABELS[category][lang]}
|
||||
</h2>
|
||||
<dl className="space-y-6">
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="space-y-16 lg:col-span-9">
|
||||
{categories.map(({ category, entries }) => (
|
||||
<section key={category} id={`cat-${category}`} className="scroll-mt-24">
|
||||
<h2 className="text-h3 text-ink-muted">{CATEGORY_LABELS[category][lang]}</h2>
|
||||
<dl className="mt-4">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} id={entry.id} className="scroll-mt-24">
|
||||
<dt className="flex flex-wrap items-baseline gap-2 mb-1">
|
||||
<span className="font-mono text-sm font-semibold text-sky-400">{entry.display}</span>
|
||||
{entry.fullName && (
|
||||
<span className="text-sm text-slate-300">{entry.fullName[lang]}</span>
|
||||
)}
|
||||
<div
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
className="grid scroll-mt-24 gap-x-8 gap-y-1 border-t border-line py-4 sm:grid-cols-[12rem_1fr]"
|
||||
>
|
||||
<dt>
|
||||
<span className="font-mono text-sm font-semibold text-ink">{entry.display}</span>
|
||||
{entry.fullName && <span className="block text-sm text-ink-muted">{entry.fullName[lang]}</span>}
|
||||
</dt>
|
||||
<dd className="text-sm text-slate-500 leading-relaxed">{entry.definition[lang]}</dd>
|
||||
<dd className="text-ink-soft">{entry.definition[lang]}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+91
-532
@@ -1,553 +1,112 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ChartBarIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
PuzzlePieceIcon,
|
||||
ShieldCheckIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
const DRIVERS = [
|
||||
'Arista EOS', 'Cisco IOS', 'Cisco IOS-XE', 'Cisco NX-OS', 'Fritz!Box',
|
||||
'HP ProCurve / Aruba', 'Juniper JunOS', 'Linux', 'Netgear', 'OpenMediaVault',
|
||||
'OpenWRT', 'OPNsense', 'Proxmox VE', 'Sonos', 'TP-Link Jetstream', 'Zyxel',
|
||||
]
|
||||
|
||||
function BrowserFrame({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
||||
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||
<span className="ml-4 text-xs text-slate-500 font-mono">{label}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: 'active' | 'warning' | 'offline' }) {
|
||||
const cls =
|
||||
status === 'active' ? 'bg-green-500/20 text-green-400' :
|
||||
status === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
return (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${cls}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MockDeviceList() {
|
||||
const rows = [
|
||||
{ name: 'fw-001.lan', ip: '10.0.0.1', driver: 'OPNsense', status: 'active' as const },
|
||||
{ name: 'sw-core.lan', ip: '10.0.0.2', driver: 'HP ProCurve', status: 'warning' as const },
|
||||
{ name: 'ap-001.lan', ip: '10.0.0.10', driver: 'OpenWRT', status: 'active' as const },
|
||||
{ name: 'ap-002.lan', ip: '10.0.0.11', driver: 'OpenWRT', status: 'warning' as const },
|
||||
{ name: 'pve-01.lan', ip: '10.0.0.20', driver: 'Proxmox VE', status: 'active' as const },
|
||||
{ name: 'nas-01.lan', ip: '10.0.0.30', driver: 'OpenMediaVault', status: 'active' as const },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-slate-950 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-100">Devices</span>
|
||||
<span className="text-xs px-2.5 py-1 rounded-md border border-slate-700 text-slate-400">Discovery</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-800 overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900">
|
||||
<th className="px-3 py-2 text-left font-medium text-slate-500">Hostname</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-slate-500">IP</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-slate-500 hidden sm:table-cell">Driver</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40">
|
||||
<td className="px-3 py-2 font-mono text-sky-400">{r.name}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-400">{r.ip}</td>
|
||||
<td className="px-3 py-2 text-slate-400 hidden sm:table-cell">{r.driver}</td>
|
||||
<td className="px-3 py-2"><StatusBadge status={r.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MockDeviceDetail() {
|
||||
return (
|
||||
<div className="bg-slate-950 p-4">
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4 mb-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-100">ap-001.lan</p>
|
||||
<p className="text-xs text-slate-500 font-mono">10.0.0.10</p>
|
||||
</div>
|
||||
<StatusBadge status="active" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span className="text-slate-500">Vendor</span><p className="text-slate-300">GL.iNet</p></div>
|
||||
<div><span className="text-slate-500">OS</span><p className="text-slate-300">OpenWRT 23.05</p></div>
|
||||
<div><span className="text-slate-500">Driver</span><p className="text-slate-300">openwrt</p></div>
|
||||
<div><span className="text-slate-500">Last poll</span><p className="text-slate-300">2 min ago</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs mb-3">
|
||||
{['Interfaces', 'LLDP', 'ARP', 'VLANs', 'Services'].map((t) => (
|
||||
<span key={t} className={`px-2 py-1 rounded text-xs ${t === 'Interfaces' ? 'bg-sky-600/20 text-sky-400' : 'text-slate-500 hover:text-slate-300'}`}>{t}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-800 overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="bg-slate-900 border-b border-slate-800">
|
||||
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">Interface</th>
|
||||
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">IP</th>
|
||||
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">State</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{[['br-lan', '10.0.0.10/24', 'up'], ['wlan0', '—', 'up'], ['eth0', '—', 'up']].map(([iface, ip, state]) => (
|
||||
<tr key={iface} className="border-b border-slate-800 last:border-0">
|
||||
<td className="px-3 py-1.5 font-mono text-slate-300">{iface}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-slate-400">{ip}</td>
|
||||
<td className="px-3 py-1.5"><span className="text-green-400">{state}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MockVlans() {
|
||||
return (
|
||||
<div className="bg-slate-950 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-100">VLANs</span>
|
||||
<span className="text-xs px-2.5 py-1 rounded-md bg-sky-600 text-white">+ Add VLAN</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ id: 10, name: 'management', devices: 6, drift: false },
|
||||
{ id: 20, name: 'iot', devices: 4, drift: true },
|
||||
{ id: 30, name: 'servers', devices: 3, drift: false },
|
||||
{ id: 40, name: 'guest', devices: 2, drift: false },
|
||||
].map((v) => (
|
||||
<div key={v.id} className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900 px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-slate-500 w-8">#{v.id}</span>
|
||||
<span className="text-xs text-slate-300">{v.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-500">{v.devices} devices</span>
|
||||
{v.drift
|
||||
? <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">drift</span>
|
||||
: <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">clean</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
|
||||
<p className="text-xs text-yellow-400 font-medium mb-1">Drift detected — VLAN 20 (iot)</p>
|
||||
<p className="text-xs text-slate-400">ap-002.lan: VLAN name mismatch. Expected "iot", got "IoT-devices".</p>
|
||||
<button className="mt-2 text-xs px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-500 text-white transition-colors">Fix now</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MockConfigDiff() {
|
||||
const snapshots = [
|
||||
{ id: 'a3f9c1', when: '2 min ago', label: 'current' },
|
||||
{ id: '7e2b04', when: '1 h ago' },
|
||||
{ id: 'd819e6', when: '6 h ago' },
|
||||
]
|
||||
return (
|
||||
<div className="bg-slate-950 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-100">Config — fw-001.lan</span>
|
||||
<button className="text-xs px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-500 text-white transition-colors">Restore</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{snapshots.map((s) => (
|
||||
<span key={s.id} className={`text-xs font-mono px-2 py-1 rounded border ${s.label ? 'border-sky-500/40 bg-sky-500/10 text-sky-400' : 'border-slate-800 text-slate-500'}`}>
|
||||
{s.id} <span className="text-slate-600">· {s.when}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-800 overflow-hidden font-mono text-xs">
|
||||
<div className="px-3 py-1.5 bg-slate-900 text-slate-500 border-b border-slate-800">7e2b04 → a3f9c1</div>
|
||||
<div className="px-3 py-1 bg-red-500/10 text-red-400">- set firewall.rule_42.destination_port='22'</div>
|
||||
<div className="px-3 py-1 bg-green-500/10 text-green-400">+ set firewall.rule_42.destination_port='2222'</div>
|
||||
<div className="px-3 py-1 text-slate-500"> commit</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
|
||||
<p className="text-xs text-yellow-400 font-medium">Unauthorized change detected — fw-001.lan</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Configuration changed outside netOrk between the last two polls.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MockDashboard() {
|
||||
const widgets = [
|
||||
{ label: 'Stats', span: 'col-span-2' },
|
||||
{ label: 'Device Warnings', span: 'col-span-1' },
|
||||
{ label: 'Network Topology', span: 'col-span-2' },
|
||||
{ label: 'EOL Status', span: 'col-span-1' },
|
||||
]
|
||||
return (
|
||||
<div className="bg-slate-950 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-100">My Dashboard</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-sky-500/15 text-sky-400 border border-sky-500/20">shared</span>
|
||||
<span className="text-xs px-2.5 py-1 rounded-md bg-sky-600 text-white">+ Add Widget</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{widgets.map((w) => (
|
||||
<div key={w.label} className={`${w.span} rounded-lg border border-dashed border-slate-700 bg-slate-900 p-3 h-16 flex items-center justify-center`}>
|
||||
<span className="text-xs text-slate-500">{w.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MockCompliance() {
|
||||
const checks = [
|
||||
{ label: 'Asset inventory', detail: '18 / 18 devices tracked', ok: true },
|
||||
{ label: 'Patch status', detail: 'All devices polled < 60 min', ok: true },
|
||||
{ label: 'Wazuh agents', detail: '15 / 18 agents active', ok: false },
|
||||
{ label: 'Syslog forwarding', detail: '14 / 18 forwarding to Graylog', ok: false },
|
||||
{ label: 'Config drift', detail: '0 drifted devices', ok: true },
|
||||
{ label: 'Audit log', detail: '23 actions logged (24 h)', ok: true },
|
||||
]
|
||||
return (
|
||||
<div className="bg-slate-950 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-100">Compliance overview — HQ</span>
|
||||
<span className="text-xs font-mono text-sky-500">Art. 21 NIS2</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{checks.map((c) => (
|
||||
<div key={c.label} className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900 px-3 py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={`text-xs font-semibold w-3 ${c.ok ? 'text-green-400' : 'text-yellow-400'}`}>
|
||||
{c.ok ? '✓' : '⚠'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-300">{c.label}</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{c.detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MockSecurity() {
|
||||
return (
|
||||
<div className="bg-slate-950 p-4">
|
||||
<div className="mb-3">
|
||||
<span className="text-xs font-semibold text-slate-100">Security — ap-001.lan</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-slate-300">Wazuh Agent</span>
|
||||
<StatusBadge status="active" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="text-center rounded bg-red-500/10 border border-red-500/20 py-1.5">
|
||||
<p className="text-red-400 font-semibold">2</p>
|
||||
<p className="text-slate-500">Critical</p>
|
||||
</div>
|
||||
<div className="text-center rounded bg-yellow-500/10 border border-yellow-500/20 py-1.5">
|
||||
<p className="text-yellow-400 font-semibold">7</p>
|
||||
<p className="text-slate-500">High</p>
|
||||
</div>
|
||||
<div className="text-center rounded bg-blue-500/10 border border-blue-500/20 py-1.5">
|
||||
<p className="text-blue-400 font-semibold">14</p>
|
||||
<p className="text-slate-500">Medium</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-300">Graylog syslog</span>
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">forwarding</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">10.0.0.50:514 (UDP)</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-300">CrowdSec</span>
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">active</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">3 decisions · 0 bans (24 h)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import Shot from '../components/Shot'
|
||||
import {
|
||||
ButtonLink,
|
||||
Container,
|
||||
CtaBand,
|
||||
Eyebrow,
|
||||
H2,
|
||||
RuleList,
|
||||
Section,
|
||||
Split,
|
||||
Steps,
|
||||
TextLink,
|
||||
} from '../components/ui'
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useLang()
|
||||
const h = t.home
|
||||
usePageTitle(t.meta.home)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 1 — Hero */}
|
||||
<section className="py-24 md:py-32">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-slate-100 leading-tight mb-6">
|
||||
{h.hero.line1}<br />
|
||||
<span className="text-sky-400">{h.hero.line2}</span>
|
||||
</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mx-auto mb-10">
|
||||
{linkify(h.hero.sub)}
|
||||
{/* The message, and the drift itself as proof. */}
|
||||
<header className="pt-16 md:pt-24">
|
||||
<Container>
|
||||
<Eyebrow>{h.hero.eyebrow}</Eyebrow>
|
||||
<h1 className="max-w-4xl text-display">{h.hero.title}</h1>
|
||||
<p className="mt-6 max-w-2xl text-lead text-ink-soft">{h.hero.lead}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<ButtonLink to="/pricing">{h.hero.primary}</ButtonLink>
|
||||
<ButtonLink to="/#how" variant="secondary">
|
||||
{h.hero.secondary}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
<div className="mt-14 md:mt-20">
|
||||
<Shot name="drift" narrow="drift-narrow" alt={t.shots.drift} caption={h.hero.caption} eager />
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
|
||||
<Section id="how">
|
||||
<Split
|
||||
aside={
|
||||
<>
|
||||
<Eyebrow>{h.how.eyebrow}</Eyebrow>
|
||||
<H2>{h.how.title}</H2>
|
||||
<p className="mt-6 text-ink-soft">{h.how.intro}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Steps items={h.how.steps} />
|
||||
</Split>
|
||||
</Section>
|
||||
|
||||
<Section id="hardware" band>
|
||||
<Split
|
||||
aside={
|
||||
<>
|
||||
<Eyebrow>{h.hardware.eyebrow}</Eyebrow>
|
||||
<H2>{h.hardware.title}</H2>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<RuleList items={h.hardware.groups.map((g) => ({ term: g.title, body: g.body }))} />
|
||||
<p className="mt-6 border-t border-line pt-5 text-sm text-ink-muted">{h.hardware.note}</p>
|
||||
</Split>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<div className="max-w-2xl">
|
||||
<Eyebrow>{h.vulns.eyebrow}</Eyebrow>
|
||||
<H2>{h.vulns.title}</H2>
|
||||
<p className="mt-6 text-lead text-ink-soft">{h.vulns.body}</p>
|
||||
<p className="mt-6">
|
||||
<TextLink to="/pricing">{h.vulns.link}</TextLink>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 justify-center">
|
||||
<a
|
||||
href="mailto:christian@manivong.de"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
>
|
||||
{h.hero.cta1}
|
||||
</a>
|
||||
<Link
|
||||
to="/features"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-slate-700 hover:border-slate-500 text-slate-300 hover:text-slate-100 transition-colors"
|
||||
>
|
||||
{h.hero.cta2}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-12">
|
||||
<Shot name="vulnerabilities" alt={t.shots.vulnerabilities} caption={h.vulns.caption} />
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 mt-16">
|
||||
<BrowserFrame label="netork.local / devices">
|
||||
<MockDeviceList />
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 2 — Problem Statement */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-3xl mx-auto px-6 text-center">
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.problem)}</p>
|
||||
<Section id="included" className="pt-0 md:pt-0">
|
||||
<div className="border-t border-line-strong pt-16 md:pt-20">
|
||||
<H2>{h.included.title}</H2>
|
||||
<div className="mt-10">
|
||||
<RuleList
|
||||
columns={2}
|
||||
items={h.included.items.map((i) => ({ term: i.title, body: i.body }))}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 3 — Core Capabilities */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap1.title}</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap1.body)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<ChartBarIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap2.title}</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap2.body)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<WrenchScrewdriverIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap3.title}</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap3.body)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 4 — Driver Grid */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.driversHeading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl mx-auto mb-10">{linkify(h.driversSub)}</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{DRIVERS.map((d) => (
|
||||
<span key={d} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-slate-700 bg-slate-900 text-xs font-medium text-slate-300">
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{h.driversLink}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 5 — Screenshot Walkthrough */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6 space-y-24">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="mt-12 grid gap-4 border-t border-line pt-6 sm:grid-cols-[8rem_1fr]">
|
||||
<p className="text-eyebrow font-semibold uppercase text-accent">{h.nis2.label}</p>
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot1.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot1.body)}</p>
|
||||
</div>
|
||||
<BrowserFrame label="netork.local / devices / ap-001">
|
||||
<MockDeviceDetail />
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<BrowserFrame label="netork.local / vlans">
|
||||
<MockVlans />
|
||||
</BrowserFrame>
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot2.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot2.body)}</p>
|
||||
<p className="max-w-2xl text-ink-soft">{h.nis2.body}</p>
|
||||
<p className="mt-3">
|
||||
<TextLink to="/nis2">{h.nis2.link}</TextLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot3.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot3.body)}</p>
|
||||
</div>
|
||||
<BrowserFrame label="netork.local / devices / ap-001 / security">
|
||||
<MockSecurity />
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<BrowserFrame label="netork.local / devices / fw-001 / config">
|
||||
<MockConfigDiff />
|
||||
</BrowserFrame>
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot4.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot4.body)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot5.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot5.body)}</p>
|
||||
</div>
|
||||
<BrowserFrame label="netork.local / dashboards / my-dashboard">
|
||||
<MockDashboard />
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 5b — NIS2 */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-sky-500 mb-3">{h.nis2Label}</p>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.nis2Heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed mb-8">{linkify(h.nis2Body)}</p>
|
||||
<div className="mb-8">
|
||||
<Link to="/nis2" className="inline-flex items-center gap-2 text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
<ShieldCheckIcon className="h-4 w-4" />
|
||||
{h.nis2Link}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{h.nis2Items.map((m) => (
|
||||
<div key={m.art} className="flex gap-4">
|
||||
<span className="mt-0.5 shrink-0 w-28 text-xs font-mono text-sky-500">{m.art}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{m.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{linkify(m.detail)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<BrowserFrame label="netork.local / compliance / HQ">
|
||||
<MockCompliance />
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 6 — Plugin System */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 md:p-12">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.pluginsHeading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mb-6">{linkify(h.pluginsBody)}</p>
|
||||
<Link to="/plugins" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{h.pluginsLink}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 7 — Deployment */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.deployHeading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.deployBody)}</p>
|
||||
</div>
|
||||
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6 font-mono text-sm text-slate-300 overflow-x-auto">
|
||||
<code>{`# Clone + configure
|
||||
git clone https://gitea.example.com/netork/netork.git
|
||||
cp .env.example .env
|
||||
# edit .env (DB URL, Redis password, secret key)
|
||||
|
||||
# Deploy
|
||||
bash scripts/deploy.sh 192.168.1.10`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 8 — CTA */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.ctaHeading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed mb-8">{h.ctaBody}</p>
|
||||
<a
|
||||
href="mailto:christian@manivong.de"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
>
|
||||
{h.ctaButton}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<CtaBand
|
||||
title={h.cta.title}
|
||||
body={h.cta.body}
|
||||
action={<ButtonLink to="/pricing">{h.cta.button}</ButtonLink>}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { Container, PageHeader } from '../components/ui'
|
||||
|
||||
/**
|
||||
* Impressum and Datenschutz. Placeholders until the owner supplies the text;
|
||||
* netork.io must not go live with the placeholder box still showing.
|
||||
*/
|
||||
export default function Legal({ page }: { page: 'imprint' | 'privacy' }) {
|
||||
const { t } = useLang()
|
||||
const l = t.legal[page]
|
||||
usePageTitle(t.meta[page])
|
||||
|
||||
return (
|
||||
<div className="pb-24">
|
||||
<PageHeader title={l.title} />
|
||||
<Container>
|
||||
<div className="max-w-measure rounded-lg border border-drift/40 bg-drift-soft p-5">
|
||||
<p className="text-sm font-semibold text-drift">{t.legal.placeholder}</p>
|
||||
<p className="mt-2 text-ink-soft">{l.body}</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+40
-218
@@ -1,235 +1,57 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ShieldCheckIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Coverage = 'covered' | 'partial' | 'roadmap' | 'na'
|
||||
type Requirement = { article: string; label: string; coverage: Coverage; netork: string }
|
||||
type EvidenceBlock = { trigger: string; produces: string[] }
|
||||
type ComingItem = { title: string; detail: string }
|
||||
|
||||
const REQUIREMENTS: Record<'en' | 'de', Requirement[]> = {
|
||||
en: [
|
||||
{ article: 'Art. 21 (2a)', label: 'Risk analysis & information system security policies', coverage: 'partial', netork: 'Config drift detection, SNMP health metrics, and security agent coverage across all devices provide a continuous risk baseline. A formal risk register is out of scope for netOrk.' },
|
||||
{ article: 'Art. 21 (2b)', label: 'Incident handling', coverage: 'partial', netork: 'Wazuh alert history, CrowdSec decisions, and Graylog syslog per device surface incidents at the network layer. A structured incident record with NIS2 Art. 23 reporting timers is on the roadmap.' },
|
||||
{ article: 'Art. 21 (2c)', label: 'Business continuity, backup management, disaster recovery', coverage: 'partial', netork: 'Every poll captures a configuration snapshot into a local Git repository — full history, a side-by-side diff viewer between any two points in time, and one-click restore for OPNsense. Backup/recovery for full device state beyond configuration is out of scope.' },
|
||||
{ article: 'Art. 21 (2d)', label: 'Supply chain security', coverage: 'covered', netork: 'Vendor, model, firmware, and OS version are tracked per device after every poll. The EOL Tracking plugin checks each device\'s OS version against the endoflife.date API daily and flags unsupported or soon-to-be-unsupported software.' },
|
||||
{ article: 'Art. 21 (2e)', label: 'Vulnerability handling in acquisition, development & maintenance', coverage: 'covered', netork: 'Per-device update status and installed package list tracked on every poll. Wazuh CVE counts by severity (critical / high / medium) linked directly to each device record. CVE cross-reference against NVD/OSV (without Wazuh) is on the roadmap.' },
|
||||
{ article: 'Art. 21 (2f)', label: 'Assessing effectiveness of cybersecurity measures', coverage: 'partial', netork: 'The audit log records all orchestration actions. A per-site compliance dashboard (on roadmap) will aggregate security agent coverage, drift status, and patch metrics into a single view.' },
|
||||
{ article: 'Art. 21 (2g)', label: 'Basic cyber hygiene & cybersecurity training', coverage: 'na', netork: 'Out of scope for a network orchestration platform. Training and hygiene policies are handled at the organizational level.' },
|
||||
{ article: 'Art. 21 (2h)', label: 'Access control, asset management, human resources security', coverage: 'covered', netork: 'Full device inventory maintained automatically via discovery and continuous polling. RBAC with four built-in roles (viewer / operator / engineer / administrator) and custom role combinations. Complete audit log of all orchestration actions, filterable by date range, user, action, or resource — export to CSV or PDF for audit submissions.' },
|
||||
{ article: 'Art. 21 (2i)', label: 'Multi-factor authentication', coverage: 'covered', netork: 'TOTP-based MFA for netOrk user accounts — authenticator app at login, backup codes for emergencies, session invalidation on TOTP changes, enforceable per role.' },
|
||||
{ article: 'Art. 21 (2j)', label: 'Physical and environmental security', coverage: 'na', netork: 'Out of scope. Physical security of the infrastructure hosting netOrk is an organizational and facility concern.' },
|
||||
],
|
||||
de: [
|
||||
{ article: 'Art. 21 (2a)', label: 'Risikoanalyse und Sicherheitsrichtlinien für Informationssysteme', coverage: 'partial', netork: 'Konfigurationsdrift-Erkennung, SNMP-Gesundheitsmetriken und Security-Agent-Abdeckung über alle Geräte liefern eine kontinuierliche Risiko-Baseline. Ein formales Risikoregister liegt außerhalb des Scopes von netOrk.' },
|
||||
{ article: 'Art. 21 (2b)', label: 'Bewältigung von Sicherheitsvorfällen', coverage: 'partial', netork: 'Wazuh-Alert-Historie, CrowdSec-Entscheidungen und Graylog-Syslog pro Gerät decken Vorfälle auf Netzwerkebene auf. Ein strukturierter Incident-Datensatz mit NIS2 Art. 23 Melde-Timern ist auf der Roadmap.' },
|
||||
{ article: 'Art. 21 (2c)', label: 'Geschäftskontinuität, Backup-Management, Disaster Recovery', coverage: 'partial', netork: 'Bei jedem Poll wird ein Konfigurationssnapshot in einem lokalen Git-Repository gespeichert — vollständige Historie, ein Side-by-Side-Diff-Viewer zwischen beliebigen Zeitpunkten und Ein-Klick-Restore für OPNsense. Backup/Recovery für den vollständigen Gerätezustand über die Konfiguration hinaus liegt außerhalb des Scopes.' },
|
||||
{ article: 'Art. 21 (2d)', label: 'Supply-Chain-Sicherheit', coverage: 'covered', netork: 'Hersteller, Modell, Firmware und OS-Version werden nach jedem Poll pro Gerät erfasst. Das EOL-Tracking-Plugin gleicht die OS-Version jedes Geräts täglich mit der endoflife.date-API ab und kennzeichnet nicht mehr oder bald nicht mehr unterstützte Software.' },
|
||||
{ article: 'Art. 21 (2e)', label: 'Schwachstellenbehandlung bei Erwerb, Entwicklung & Wartung', coverage: 'covered', netork: 'Update-Status und installierte Paketliste pro Gerät werden bei jedem Poll erfasst. Wazuh-CVE-Anzahl nach Schweregrad (kritisch / hoch / mittel) direkt mit jedem Gerätedatensatz verknüpft. CVE-Abgleich gegen NVD/OSV (ohne Wazuh) ist auf der Roadmap.' },
|
||||
{ article: 'Art. 21 (2f)', label: 'Beurteilung der Wirksamkeit von Cybersicherheitsmaßnahmen', coverage: 'partial', netork: 'Das Audit-Log erfasst alle Orchestrierungsaktionen. Ein Compliance-Dashboard pro Standort (auf der Roadmap) wird Security-Agent-Abdeckung, Drift-Status und Patch-Metriken in einer Ansicht zusammenfassen.' },
|
||||
{ article: 'Art. 21 (2g)', label: 'Grundlegende Cyberhygiene und Cybersicherheitsschulungen', coverage: 'na', netork: 'Außerhalb des Scopes einer Netzwerk-Orchestrierungsplattform. Schulungen und Hygiene-Richtlinien werden auf Organisationsebene gehandhabt.' },
|
||||
{ article: 'Art. 21 (2h)', label: 'Zugangskontrolle, Asset-Management, Personalsicherheit', coverage: 'covered', netork: 'Vollständiges Geräteinventar automatisch über Discovery und kontinuierliches Polling gepflegt. RBAC mit vier integrierten Rollen (Betrachter / Operator / Ingenieur / Administrator) und benutzerdefinierten Rollenkombinationen. Vollständiges Audit-Log aller Orchestrierungsaktionen, filterbar nach Datumsbereich, Benutzer, Aktion oder Ressource — Export als CSV oder PDF für Audit-Einreichungen.' },
|
||||
{ article: 'Art. 21 (2i)', label: 'Multi-Faktor-Authentifizierung', coverage: 'covered', netork: 'TOTP-basierte MFA für netOrk-Benutzerkonten — Authenticator-App beim Login, Backup-Codes für Notfälle, Session-Invalidierung bei TOTP-Änderungen, pro Rolle erzwingbar.' },
|
||||
{ article: 'Art. 21 (2j)', label: 'Physische und umgebungsbezogene Sicherheit', coverage: 'na', netork: 'Außerhalb des Scopes. Die physische Sicherheit der Infrastruktur, die netOrk hostet, ist eine organisatorische und gebäudetechnische Angelegenheit.' },
|
||||
],
|
||||
}
|
||||
|
||||
const EVIDENCE: Record<'en' | 'de', EvidenceBlock[]> = {
|
||||
en: [
|
||||
{
|
||||
trigger: 'Every device poll',
|
||||
produces: [
|
||||
'Installed package list with versions',
|
||||
'Available update count',
|
||||
'Interface status, ARP table, DHCP leases',
|
||||
'VLAN membership vs. desired state (drift)',
|
||||
'SNMP health metrics (CPU, memory, interface counters)',
|
||||
'Wazuh agent status and CVE counts by severity',
|
||||
'Graylog syslog forwarding status',
|
||||
'CrowdSec decisions and ban counts',
|
||||
'Git-backed configuration snapshot, diffed against the previous one to detect unauthorized changes',
|
||||
],
|
||||
},
|
||||
{
|
||||
trigger: 'Every user action',
|
||||
produces: [
|
||||
'Audit log entry: user, timestamp, resource, action',
|
||||
'Before/after values for configuration changes',
|
||||
'Acknowledged warnings logged with the accepting user',
|
||||
],
|
||||
},
|
||||
{
|
||||
trigger: 'On demand',
|
||||
produces: [
|
||||
'Topology graph — network segmentation view',
|
||||
'Subnet browser — IP space coverage',
|
||||
'VLAN matrix — which devices carry which VLANs',
|
||||
'Configuration diff between any two snapshots; one-click restore (OPNsense)',
|
||||
'Audit log export to PDF / CSV, filtered by date range, user, action, or resource',
|
||||
],
|
||||
},
|
||||
],
|
||||
de: [
|
||||
{
|
||||
trigger: 'Bei jedem Geräte-Poll',
|
||||
produces: [
|
||||
'Installierte Paketliste mit Versionen',
|
||||
'Anzahl verfügbarer Updates',
|
||||
'Schnittstellenstatus, ARP-Tabelle, DHCP-Leases',
|
||||
'VLAN-Zugehörigkeit vs. Sollzustand (Drift)',
|
||||
'SNMP-Gesundheitsmetriken (CPU, Speicher, Schnittstellenzähler)',
|
||||
'Wazuh-Agent-Status und CVE-Anzahl nach Schweregrad',
|
||||
'Graylog-Syslog-Weiterleitungsstatus',
|
||||
'CrowdSec-Entscheidungen und Ban-Anzahl',
|
||||
'Git-basierter Konfigurationssnapshot, gegen den vorherigen geprüft, um nicht autorisierte Änderungen zu erkennen',
|
||||
],
|
||||
},
|
||||
{
|
||||
trigger: 'Bei jeder Benutzeraktion',
|
||||
produces: [
|
||||
'Audit-Log-Eintrag: Benutzer, Zeitstempel, Ressource, Aktion',
|
||||
'Vorher/Nachher-Werte für Konfigurationsänderungen',
|
||||
'Bestätigte (acked) Warnungen werden mit dem bestätigenden Benutzer protokolliert',
|
||||
],
|
||||
},
|
||||
{
|
||||
trigger: 'Auf Abruf',
|
||||
produces: [
|
||||
'Topologie-Graph — Netzwerksegmentierungs-Ansicht',
|
||||
'Subnetz-Browser — IP-Raum-Abdeckung',
|
||||
'VLAN-Matrix — welche Geräte welche VLANs führen',
|
||||
'Konfigurations-Diff zwischen zwei beliebigen Snapshots; Ein-Klick-Restore (OPNsense)',
|
||||
'Audit-Log-Export als PDF / CSV, gefiltert nach Datumsbereich, Benutzer, Aktion oder Ressource',
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const COMING: Record<'en' | 'de', ComingItem[]> = {
|
||||
en: [
|
||||
{ title: 'CVE tracking per device', detail: 'Automatic cross-reference of installed packages against NVD / OSV — no Wazuh agent required.' },
|
||||
{ title: 'Compliance dashboard', detail: 'Per-site Art. 21 checklist: asset coverage, patch status, agent deployment, drift, syslog, audit activity.' },
|
||||
{ title: 'Incident workflow', detail: 'Structured incident records with NIS2 Art. 23 reporting timers (24 h / 72 h) and external webhook delivery.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'CVE-Tracking pro Gerät', detail: 'Automatischer Abgleich installierter Pakete gegen NVD / OSV — kein Wazuh-Agent erforderlich.' },
|
||||
{ title: 'Compliance-Dashboard', detail: 'Art. 21-Checkliste pro Standort: Asset-Abdeckung, Patch-Status, Agent-Deployment, Drift, Syslog, Audit-Aktivität.' },
|
||||
{ title: 'Incident-Workflow', detail: 'Strukturierte Incident-Datensätze mit NIS2 Art. 23 Melde-Timern (24 h / 72 h) und externer Webhook-Zustellung.' },
|
||||
],
|
||||
}
|
||||
|
||||
function CoverageTag({ coverage, labels }: { coverage: Coverage; labels: Record<string, string> }) {
|
||||
const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap border'
|
||||
if (coverage === 'covered') return <span className={`${base} bg-green-500/15 text-green-400 border-green-500/20`}>{labels.covered}</span>
|
||||
if (coverage === 'partial') return <span className={`${base} bg-yellow-500/15 text-yellow-400 border-yellow-500/20`}>{labels.partial}</span>
|
||||
if (coverage === 'roadmap') return <span className={`${base} bg-sky-500/15 text-sky-400 border-sky-500/20`}>{labels.roadmap}</span>
|
||||
return <span className={`${base} bg-slate-700/50 text-slate-500 border-slate-700`}>{labels.na}</span>
|
||||
}
|
||||
import Shot from '../components/Shot'
|
||||
import { ButtonLink, CtaBand, H2, PageHeader, Section, Split, Status } from '../components/ui'
|
||||
|
||||
export default function Nis2() {
|
||||
const { lang, t } = useLang()
|
||||
const reqs = REQUIREMENTS[lang]
|
||||
const evidence = EVIDENCE[lang]
|
||||
const coming = COMING[lang]
|
||||
const coverageLabels = t.nis2.coverageLabels
|
||||
const legendLabels = t.nis2.legendLabels
|
||||
const { t } = useLang()
|
||||
const n = t.nis2
|
||||
usePageTitle(t.meta.nis2)
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<>
|
||||
<PageHeader eyebrow={n.eyebrow} title={n.title} lead={linkify(n.lead)} />
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<ShieldCheckIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.nis2.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.nis2.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.nis2.mappingHeading}
|
||||
</h2>
|
||||
<div className="space-y-0 rounded-xl border border-slate-800 overflow-hidden">
|
||||
{reqs.map((r, i) => (
|
||||
<div key={r.article} className={`p-5 ${i < reqs.length - 1 ? 'border-b border-slate-800' : ''}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-sky-500 shrink-0">{r.article}</span>
|
||||
<span className="text-sm font-medium text-slate-200">{r.label}</span>
|
||||
</div>
|
||||
<CoverageTag coverage={r.coverage} labels={coverageLabels} />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 leading-relaxed md:pl-24">{linkify(r.netork)}</p>
|
||||
<Section className="pt-0 md:pt-0">
|
||||
<H2>{n.tableTitle}</H2>
|
||||
<dl className="mt-10">
|
||||
{n.requirements.map((r) => (
|
||||
<div
|
||||
key={r.article}
|
||||
className="grid gap-x-8 gap-y-2 border-t border-line py-5 md:grid-cols-[6rem_16rem_1fr_8rem]"
|
||||
>
|
||||
<dt className="font-mono text-sm text-accent">Art. 21 {r.article}</dt>
|
||||
<dt className="text-h3 text-ink">{r.label}</dt>
|
||||
<dd className="text-ink-soft">{linkify(r.body)}</dd>
|
||||
<dd className="md:text-right">
|
||||
<Status coverage={r.coverage} label={n.coverage[r.coverage]} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-500">
|
||||
{(['covered', 'partial', 'roadmap', 'na'] as Coverage[]).map((c) => (
|
||||
<span key={c} className="flex items-center gap-1.5">
|
||||
<CoverageTag coverage={c} labels={coverageLabels} />
|
||||
{legendLabels[c]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-2 pb-2 border-b border-slate-800">
|
||||
{t.nis2.evidenceHeading}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">{linkify(t.nis2.evidenceSub)}</p>
|
||||
<div className="space-y-6">
|
||||
{evidence.map((e) => (
|
||||
<div key={e.trigger} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-xs font-semibold text-sky-500 uppercase tracking-widest mb-3">{e.trigger}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{e.produces.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm text-slate-400">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||
{linkify(item)}
|
||||
<Section band>
|
||||
<Split aside={<H2>{n.evidenceTitle}</H2>}>
|
||||
<ul>
|
||||
{n.evidence.map((e) => (
|
||||
<li key={e} className="border-t border-line py-4 text-ink-soft first:border-t-0 first:pt-0">
|
||||
{linkify(e)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Split>
|
||||
<div className="mt-14">
|
||||
<Shot name="audit-log" alt={t.shots.auditLog} caption={n.auditCaption} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.nis2.comingHeading}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{coming.map((item) => (
|
||||
<div key={item.title} className="flex gap-4">
|
||||
<div className="mt-2 shrink-0 h-1.5 w-1.5 rounded-full bg-sky-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{item.title}</p>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{linkify(item.detail)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Link to="/roadmap" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{lang === 'de' ? 'Vollständige Roadmap →' : 'Full roadmap →'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.nis2.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.nis2.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.nis2.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CtaBand
|
||||
title={t.personas.cta.title}
|
||||
body={t.personas.cta.body}
|
||||
action={<ButtonLink to="/pricing">{t.personas.cta.button}</ButtonLink>}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { linkify } from '../lib/glossary'
|
||||
import Shot from '../components/Shot'
|
||||
import { ButtonLink, Container, CtaBand, H2, PageHeader, RuleList, Section, Split } from '../components/ui'
|
||||
|
||||
export type PersonaId = 'itDepartment' | 'itSupport' | 'msp'
|
||||
|
||||
/** One template for the three "who it's for" pages; only the copy differs. */
|
||||
export default function Persona({ id }: { id: PersonaId }) {
|
||||
const { t } = useLang()
|
||||
const shared = t.personas
|
||||
const p = shared[id]
|
||||
usePageTitle(p.title)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader eyebrow={p.eyebrow} title={p.heading} lead={p.lead} />
|
||||
|
||||
{/* The primary audience sees the drift itself; the others read about it. */}
|
||||
{id === 'itDepartment' && (
|
||||
<Container className="pb-8">
|
||||
<Shot name="drift" narrow="drift-narrow" alt={t.shots.drift} />
|
||||
</Container>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<Split aside={<H2>{shared.problems}</H2>}>
|
||||
<RuleList items={p.problems.map((i) => ({ term: i.title, body: linkify(i.body) }))} />
|
||||
</Split>
|
||||
</Section>
|
||||
|
||||
<Section className="pt-0 md:pt-0">
|
||||
<Split aside={<H2>{shared.helps}</H2>}>
|
||||
<RuleList items={p.helps.map((i) => ({ term: i.title, body: linkify(i.body) }))} />
|
||||
</Split>
|
||||
</Section>
|
||||
|
||||
<CtaBand
|
||||
title={shared.cta.title}
|
||||
body={shared.cta.body}
|
||||
action={<ButtonLink to="/pricing">{shared.cta.button}</ButtonLink>}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+30
-228
@@ -1,250 +1,52 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PuzzlePieceIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Building = { title: string; body: string }
|
||||
type BuiltinPlugin = { label: string; body: string; hosts: string[]; hooks: string[] }
|
||||
type Step = { title: string; body: string }
|
||||
type HookKind = { name: string; behavior: string; use: string }
|
||||
|
||||
const BUILDING_BLOCKS: Record<'en' | 'de', Building[]> = {
|
||||
en: [
|
||||
{ title: 'Metadata (PluginMeta)', body: 'name, label, description, version, a declared permission surface (db_read, db_write, external_hosts, hooks — for review, not runtime enforcement), and an optional Celery Beat schedule.' },
|
||||
{ title: 'Router', body: 'A FastAPI APIRouter, mounted under /api/v1 at startup — but only for plugins that are enabled.' },
|
||||
{ title: 'Tasks', body: 'Celery tasks for anything that talks to an external system. Hooks queue work; they never block the event loop on I/O.' },
|
||||
{ title: 'Hooks', body: 'Handlers registered against the central event bus (hook_registry) via the @hook decorator — react to events like poll.complete.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Metadaten (PluginMeta)', body: 'name, label, description, version, eine deklarierte Permission-Surface (db_read, db_write, external_hosts, hooks — für Review, nicht zur Laufzeit erzwungen) und ein optionaler Celery-Beat-Schedule.' },
|
||||
{ title: 'Router', body: 'Ein FastAPI APIRouter, beim Start unter /api/v1 gemountet — aber nur für aktivierte Plugins.' },
|
||||
{ title: 'Tasks', body: 'Celery-Tasks für alles, was mit einem externen System spricht. Hooks reihen Arbeit ein; sie blockieren den Event-Loop nie mit I/O.' },
|
||||
{ title: 'Hooks', body: 'Handler, die über den @hook-Decorator am zentralen Event-Bus (hook_registry) registriert werden — reagieren auf Events wie poll.complete.' },
|
||||
],
|
||||
}
|
||||
|
||||
const BUILTIN: Record<'en' | 'de', BuiltinPlugin[]> = {
|
||||
en: [
|
||||
{ label: 'Wazuh Security', body: 'Syncs device enrollment with the Wazuh manager and surfaces agent health warnings, vulnerability counts, alerts, and CIS benchmark scores.', hosts: ['wazuh-manager:55000', 'wazuh-indexer:9200'], hooks: ['poll.complete'] },
|
||||
{ label: 'Graylog Syslog', body: 'Checks whether syslog-capable devices forward logs to Graylog via rsyslog, and can automatically write the forwarding rule.', hosts: [], hooks: [] },
|
||||
{ label: 'CrowdSec', body: 'Surfaces org-level CrowdSec security health data per device: active decisions, blocked requests, remediation metrics, and top attack scenarios.', hosts: ['admin.api.crowdsec.net:443'], hooks: [] },
|
||||
{ label: 'apt-cacher-ng', body: "Auto-detects apt-cacher-ng on Docker hosts and configures the APT proxy on devices within the same site so APT traffic is routed through the local cache.", hosts: [], hooks: ['poll.complete'] },
|
||||
{ label: 'EOL Tracking', body: "Flags devices running end-of-life or soon-to-be-end-of-life firmware/OS versions, checked daily against the endoflife.date API. Reuses the OS version already collected during polling — no extra device I/O.", hosts: ['endoflife.date'], hooks: [] },
|
||||
],
|
||||
de: [
|
||||
{ label: 'Wazuh Security', body: 'Synchronisiert die Geräte-Registrierung mit dem Wazuh-Manager und zeigt Agent-Health-Warnungen, Schwachstellenanzahl, Alerts und CIS-Benchmark-Scores an.', hosts: ['wazuh-manager:55000', 'wazuh-indexer:9200'], hooks: ['poll.complete'] },
|
||||
{ label: 'Graylog Syslog', body: 'Prüft, ob syslog-fähige Geräte Logs via rsyslog an Graylog weiterleiten, und kann die Weiterleitungsregel automatisch schreiben.', hosts: [], hooks: [] },
|
||||
{ label: 'CrowdSec', body: 'Zeigt Org-Level-CrowdSec-Sicherheitsdaten pro Gerät: aktive Entscheidungen, geblockte Requests, Remediation-Metriken und Top-Angriffsszenarien.', hosts: ['admin.api.crowdsec.net:443'], hooks: [] },
|
||||
{ label: 'apt-cacher-ng', body: 'Erkennt apt-cacher-ng automatisch auf Docker-Hosts und konfiguriert den APT-Proxy auf Geräten im selben Standort, sodass APT-Traffic über den lokalen Cache läuft.', hosts: [], hooks: ['poll.complete'] },
|
||||
{ label: 'EOL-Tracking', body: 'Kennzeichnet Geräte mit End-of-Life- oder bald End-of-Life-Firmware/OS, täglich gegen die endoflife.date-API geprüft. Nutzt die beim Polling bereits erfasste OS-Version — keine zusätzliche Geräte-I/O.', hosts: ['endoflife.date'], hooks: [] },
|
||||
],
|
||||
}
|
||||
|
||||
const STEPS: Record<'en' | 'de', Step[]> = {
|
||||
en: [
|
||||
{ title: 'Declare metadata', body: 'Create netork/plugins/<name>/__init__.py with a PluginMeta — permissions are declarative, for human review, not enforced at runtime.' },
|
||||
{ title: 'Register', body: 'Implement register(): register the metadata with plugin_registry and import your hooks module so its @hook decorators run.' },
|
||||
{ title: 'Add hooks', body: 'hooks.py: handlers for the events you care about, e.g. poll.complete.' },
|
||||
{ title: 'Add a router (optional)', body: 'router.py: a FastAPI APIRouter, returned from a _get_router() callable passed to plugin_registry.register().' },
|
||||
{ title: 'Add tasks', body: 'tasks.py: Celery tasks for anything that talks to an external system — hooks should queue work, not block.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Metadaten deklarieren', body: 'netork/plugins/<name>/__init__.py mit einer PluginMeta anlegen — Permissions sind deklarativ, für menschliches Review, nicht zur Laufzeit erzwungen.' },
|
||||
{ title: 'Registrieren', body: 'register() implementieren: Metadaten bei plugin_registry registrieren und das hooks-Modul importieren, damit dessen @hook-Decorators laufen.' },
|
||||
{ title: 'Hooks hinzufügen', body: 'hooks.py: Handler für die Events, die dich interessieren, z. B. poll.complete.' },
|
||||
{ title: 'Router hinzufügen (optional)', body: 'router.py: ein FastAPI APIRouter, zurückgegeben von einem _get_router()-Callable, das an plugin_registry.register() übergeben wird.' },
|
||||
{ title: 'Tasks hinzufügen', body: 'tasks.py: Celery-Tasks für alles, was mit einem externen System spricht — Hooks sollten Arbeit einreihen, nicht blockieren.' },
|
||||
],
|
||||
}
|
||||
|
||||
const HOOK_KINDS: Record<'en' | 'de', HookKind[]> = {
|
||||
en: [
|
||||
{ name: 'fire()', behavior: 'Fire-and-forget. All handlers run concurrently; exceptions are logged, never raised. 30 s timeout per handler.', use: 'Side effects after an event, e.g. "queue a Wazuh sync after a poll completes".' },
|
||||
{ name: 'call()', behavior: 'Blocking. Handlers run sequentially by priority; exceptions propagate to the caller. 10 s timeout per handler.', use: 'When the caller needs to know a handler failed.' },
|
||||
{ name: 'transform()', behavior: 'Pipeline. Handlers run sequentially, each receiving and returning value. 10 s timeout per handler.', use: 'Letting plugins enrich or modify a value in place, e.g. annotate a payload before it\'s persisted.' },
|
||||
],
|
||||
de: [
|
||||
{ name: 'fire()', behavior: 'Fire-and-forget. Alle Handler laufen gleichzeitig; Exceptions werden geloggt, nie geworfen. 30 s Timeout pro Handler.', use: 'Seiteneffekte nach einem Event, z. B. „Wazuh-Sync nach abgeschlossenem Poll einreihen".' },
|
||||
{ name: 'call()', behavior: 'Blockierend. Handler laufen sequenziell nach Priorität; Exceptions propagieren zum Aufrufer. 10 s Timeout pro Handler.', use: 'Wenn der Aufrufer wissen muss, dass ein Handler fehlgeschlagen ist.' },
|
||||
{ name: 'transform()', behavior: 'Pipeline. Handler laufen sequenziell, jeder erhält und liefert value zurück. 10 s Timeout pro Handler.', use: 'Plugins lassen einen Wert anreichern oder verändern, z. B. ein Payload vor dem Speichern annotieren.' },
|
||||
],
|
||||
}
|
||||
|
||||
const REGISTRY_PARAGRAPHS: Record<'en' | 'de', string[]> = {
|
||||
en: [
|
||||
'Enabled state lives in the database as a Setting row: plugin.<name>.enabled = "true" | "false". The registry exposes enable() and disable(), gated behind RBAC like every other orchestration action.',
|
||||
"Routers are mounted once at FastAPI startup for every enabled plugin — FastAPI doesn't support unmounting routers at runtime, so disabling a plugin's API surface takes effect after the next deploy.",
|
||||
"Hook handlers are registered as soon as a plugin's hooks module is imported. Whether a handler is a no-op until configured is up to the handler itself — the bundled plugins check their own Settings before acting.",
|
||||
],
|
||||
de: [
|
||||
'Der Aktivierungsstatus liegt in der Datenbank als Setting-Zeile: plugin.<name>.enabled = "true" | "false". Die Registry stellt enable() und disable() bereit, abgesichert durch RBAC wie jede andere Orchestrierungsaktion.',
|
||||
'Router werden beim FastAPI-Start einmal für jedes aktivierte Plugin gemountet — FastAPI unterstützt kein Unmounten zur Laufzeit, daher wirkt sich das Deaktivieren der API-Oberfläche eines Plugins erst nach dem nächsten Deploy aus.',
|
||||
'Hook-Handler werden registriert, sobald das hooks-Modul eines Plugins importiert wird. Ob ein Handler bis zur Konfiguration ein No-op bleibt, entscheidet der Handler selbst — die mitgelieferten Plugins prüfen vor dem Handeln ihre eigenen Settings.',
|
||||
],
|
||||
}
|
||||
|
||||
const META_CODE = `# netork/plugins/myplugin/__init__.py
|
||||
from netork.plugins import PluginMeta, PluginPermissions
|
||||
from netork.plugins.registry import plugin_registry
|
||||
|
||||
_META = PluginMeta(
|
||||
name="myplugin",
|
||||
label="My Plugin",
|
||||
description="What this plugin does.",
|
||||
permissions=PluginPermissions(
|
||||
db_read=["Device"],
|
||||
external_hosts=["myservice:443"],
|
||||
hooks=["poll.complete"],
|
||||
),
|
||||
)
|
||||
|
||||
def register() -> None:
|
||||
plugin_registry.register(_META, get_router=_get_router)
|
||||
from netork.plugins.myplugin import hooks as _hooks # noqa: F401
|
||||
|
||||
def _get_router():
|
||||
from netork.plugins.myplugin.router import router
|
||||
return router`
|
||||
import { CodeBlock, H2, PageHeader, RuleList, Section, Split } from '../components/ui'
|
||||
|
||||
const HOOK_CODE = `# netork/plugins/myplugin/hooks.py
|
||||
from netork.plugins.hooks import hook
|
||||
|
||||
@hook("poll.complete", priority=100)
|
||||
async def _on_poll_complete(device_id: str, session_factory, **kwargs) -> None:
|
||||
async def on_poll_complete(device_id: str, **kwargs) -> None:
|
||||
# queue a Celery task — never block the event loop here
|
||||
from netork.plugins.myplugin.tasks import sync_device
|
||||
sync_device.apply_async(args=[device_id], queue="default")`
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
return (
|
||||
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6 font-mono text-sm text-slate-300 overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function HostTag({ value }: { value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono text-slate-400 border border-slate-700 bg-slate-950">
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Plugins() {
|
||||
const { lang, t } = useLang()
|
||||
const blocks = BUILDING_BLOCKS[lang]
|
||||
const builtin = BUILTIN[lang]
|
||||
const steps = STEPS[lang]
|
||||
const hookKinds = HOOK_KINDS[lang]
|
||||
const registryParagraphs = REGISTRY_PARAGRAPHS[lang]
|
||||
const { t } = useLang()
|
||||
const p = t.plugins
|
||||
usePageTitle(t.meta.plugins)
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<>
|
||||
<PageHeader eyebrow={p.eyebrow} title={p.title} lead={linkify(p.lead)} />
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.plugins.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.plugins.sub)}</p>
|
||||
</div>
|
||||
<Section className="pt-0 md:pt-0">
|
||||
<Split aside={<H2>{p.builtinTitle}</H2>}>
|
||||
<RuleList
|
||||
items={p.builtin.map((b) => ({
|
||||
term: b.title,
|
||||
aside: b.hosts ? <span className="font-mono text-xs font-normal text-ink-muted">{b.hosts}</span> : undefined,
|
||||
body: linkify(b.body),
|
||||
}))}
|
||||
/>
|
||||
</Split>
|
||||
</Section>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.whatHeading}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{blocks.map((b) => (
|
||||
<div key={b.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{b.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(b.body)}</p>
|
||||
<Section band>
|
||||
<Split aside={<H2>{p.buildTitle}</H2>}>
|
||||
<RuleList items={p.build.map((b) => ({ term: b.title, body: linkify(b.body) }))} />
|
||||
</Split>
|
||||
<div className="mt-14 grid gap-8 lg:grid-cols-12">
|
||||
<div className="lg:col-span-5">
|
||||
<p className="text-h3">{p.codeTitle}</p>
|
||||
<p className="mt-2 text-ink-soft">{linkify(p.enable)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.builtinHeading}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{builtin.map((p) => (
|
||||
<div key={p.label} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.label}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed mb-3">{linkify(p.body)}</p>
|
||||
{(p.hosts.length > 0 || p.hooks.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{p.hosts.map((h) => <HostTag key={h} value={h} />)}
|
||||
{p.hooks.map((h) => <HostTag key={h} value={`hook: ${h}`} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.writingHeading}
|
||||
</h2>
|
||||
<ol className="space-y-4 mb-8">
|
||||
{steps.map((s, i) => (
|
||||
<li key={s.title} className="flex gap-4">
|
||||
<span className="shrink-0 h-6 w-6 rounded-full bg-sky-600/10 text-sky-400 text-xs font-semibold flex items-center justify-center mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{s.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(s.body)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="space-y-4">
|
||||
<CodeBlock code={META_CODE} />
|
||||
<div className="min-w-0 lg:col-span-7">
|
||||
<CodeBlock code={HOOK_CODE} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.hookBusHeading}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{hookKinds.map((h) => (
|
||||
<div key={h.name} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-mono font-semibold text-sky-400 mb-1.5">{h.name}</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-2">{linkify(h.behavior)}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(h.use)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.registryHeading}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{registryParagraphs.map((p) => (
|
||||
<p key={p} className="text-sm text-slate-400 leading-relaxed">{linkify(p)}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.plugins.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.plugins.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.plugins.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; detail: string; nis2?: boolean }
|
||||
type Group = { label: string; items: Item[] }
|
||||
|
||||
const GROUPS: Record<'en' | 'de', Group[]> = {
|
||||
en: [
|
||||
{
|
||||
label: 'Planned',
|
||||
items: [
|
||||
{
|
||||
title: 'CVE tracking per device',
|
||||
detail: 'Cross-reference installed packages and OS versions against NVD / OSV. Surfaces "this device has 3 unpatched CVEs (CVSS ≥ 7)" without leaving netOrk.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Compliance dashboard',
|
||||
detail: 'Per-site Art. 21 checklist: asset coverage, patch status, security agent deployment, config drift, syslog forwarding, audit activity — aggregated into a single view.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Webhook engine',
|
||||
detail: 'Outbound HTTP webhooks for events: device discovered, config change detected, job failed, alert threshold exceeded. HMAC-SHA256 payload signing. Integrates with n8n, Slack, PagerDuty.',
|
||||
},
|
||||
{
|
||||
title: 'Live job log streaming',
|
||||
detail: 'WebSocket endpoint for real-time Celery task output in the UI. Currently streamed via SSE for fix-flows; full job log streaming for all long-running operations.',
|
||||
},
|
||||
{
|
||||
title: 'NetBox sync — manual trigger & status view',
|
||||
detail: 'POST /api/v1/netbox/sync endpoint with progress feedback and a sync history view in the UI. Currently sync runs automatically; the manual trigger and status are missing.',
|
||||
},
|
||||
{
|
||||
title: 'HashiCorp Vault integration',
|
||||
detail: 'Real secret management as the first security plugin, replacing the current Fernet-based encryption at rest for device credentials and SSH keys.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Firewall profile management — rework',
|
||||
detail: 'Push reusable firewall rule templates to OPNsense and OpenWRT devices. The existing implementation is being re-scoped from scratch — profile types, rule sets, and the push mechanism are all under review before further work lands.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Under consideration',
|
||||
items: [
|
||||
{
|
||||
title: 'VLAN visualization',
|
||||
detail: 'Heatmap or matrix view of which devices carry which VLANs, without digging through per-device VLAN lists.',
|
||||
},
|
||||
{
|
||||
title: 'Incident workflow',
|
||||
detail: 'Structured incident record tied to devices and security events. Deadline tracker for NIS2 Art. 23 reporting windows (24 h early warning, 72 h full notification). Webhook to external ticketing systems.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'mDNS scanner',
|
||||
detail: 'Discover media devices (Apple TV, Chromecast, Sonos) via mDNS/Bonjour without needing a NAPALM driver. Inventory visibility and firewall segmentation suggestions.',
|
||||
},
|
||||
{
|
||||
title: 'Prometheus metrics + Grafana dashboards',
|
||||
detail: 'Expose per-device SNMP health metrics as a Prometheus scrape endpoint. Pre-built Grafana dashboard for interface counters, CPU, memory, and poll lag.',
|
||||
},
|
||||
{
|
||||
title: 'Kubernetes Helm chart',
|
||||
detail: 'Helm chart for production-grade deployments: horizontal scaling for the API and worker pods, Secrets via Vault Agent Injector, Ingress with TLS termination.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
de: [
|
||||
{
|
||||
label: 'Geplant',
|
||||
items: [
|
||||
{
|
||||
title: 'CVE-Tracking pro Gerät',
|
||||
detail: 'CVE-Abgleich mit installierten Paketen und OS-Versionen über NVD / OSV. Zeigt „Dieses Gerät hat 3 ungepatchte CVEs (CVSS ≥ 7)" direkt in netOrk an.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Compliance-Dashboard',
|
||||
detail: 'Art. 21-Checkliste pro Standort: Asset-Abdeckung, Patch-Status, Security-Agent-Deployment, Config-Drift, Syslog-Weiterleitung, Audit-Aktivität — zusammengefasst in einer Ansicht.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Webhook-Engine',
|
||||
detail: 'Ausgehende HTTP-Webhooks für Events: Gerät entdeckt, Konfigurationsänderung erkannt, Job fehlgeschlagen, Warnschwelle überschritten. HMAC-SHA256-Payload-Signierung. Integrierbar mit n8n, Slack, PagerDuty.',
|
||||
},
|
||||
{
|
||||
title: 'Live-Job-Log-Streaming',
|
||||
detail: 'WebSocket-Endpunkt für Echtzeit-Celery-Task-Output in der UI. Aktuell wird via SSE für Fix-Flows gestreamt; vollständiges Job-Log-Streaming für alle lang laufenden Operationen geplant.',
|
||||
},
|
||||
{
|
||||
title: 'NetBox-Sync — manueller Trigger & Statusansicht',
|
||||
detail: 'POST /api/v1/netbox/sync-Endpunkt mit Fortschrittsfeedback und Sync-Verlaufsansicht in der UI. Derzeit läuft der Sync automatisch; manueller Trigger und Status fehlen noch.',
|
||||
},
|
||||
{
|
||||
title: 'HashiCorp-Vault-Integration',
|
||||
detail: 'Echtes Secret-Management als erstes Security-Plugin — löst die aktuelle Fernet-basierte Verschlüsselung von Geräte-Credentials und SSH-Schlüsseln ab.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Firewall-Profile — Überarbeitung',
|
||||
detail: 'Wiederverwendbare Firewall-Regel-Templates auf OPNsense- und OpenWRT-Geräte pushen. Die bestehende Implementierung wird von Grund auf neu bewertet — Profiltypen, Regelsätze und der Push-Mechanismus stehen vor der Weiterentwicklung auf dem Prüfstand.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'In Erwägung',
|
||||
items: [
|
||||
{
|
||||
title: 'VLAN-Visualisierung',
|
||||
detail: 'Heatmap- oder Matrixansicht, welche Geräte welche VLANs führen — ohne sich durch geräteweise VLAN-Listen zu graben.',
|
||||
},
|
||||
{
|
||||
title: 'Incident-Workflow',
|
||||
detail: 'Strukturierter Incident-Datensatz, verknüpft mit Geräten und Sicherheitsereignissen. Fristen-Tracker für NIS2 Art. 23 Meldepflichten (24 h Frühwarnung, 72 h vollständige Meldung). Webhook zu externen Ticketing-Systemen.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'mDNS-Scanner',
|
||||
detail: 'Entdeckt Mediengeräte (Apple TV, Chromecast, Sonos) über mDNS/Bonjour ohne NAPALM-Treiber. Inventarsichtbarkeit und Empfehlungen zur Firewall-Segmentierung.',
|
||||
},
|
||||
{
|
||||
title: 'Prometheus-Metriken + Grafana-Dashboards',
|
||||
detail: 'Veröffentlicht gerätebezogene SNMP-Gesundheitsmetriken als Prometheus-Scrape-Endpunkt. Vorgefertigtes Grafana-Dashboard für Schnittstellenzähler, CPU, Speicher und Poll-Lag.',
|
||||
},
|
||||
{
|
||||
title: 'Kubernetes Helm Chart',
|
||||
detail: 'Helm Chart für produktionsreife Deployments: horizontale Skalierung für API- und Worker-Pods, Secrets via Vault Agent Injector, Ingress mit TLS-Terminierung.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function Nis2Badge() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-mono font-semibold bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||
NIS2
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Roadmap() {
|
||||
const { lang, t } = useLang()
|
||||
const groups = GROUPS[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.roadmap.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">
|
||||
{t.roadmap.sub.split('NIS2')[0]}
|
||||
<Nis2Badge />
|
||||
{t.roadmap.sub.split('NIS2')[1]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-14">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{group.label}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.title} className="flex gap-4">
|
||||
<div className="mt-2 shrink-0 h-1.5 w-1.5 rounded-full bg-sky-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-slate-200">{item.title}</span>
|
||||
{item.nis2 && <Nis2Badge />}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(item.detail)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+33
-1
@@ -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: [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user