Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
992df0e6b1 | ||
|
|
f70fec496a | ||
|
|
369f66afdc | ||
|
|
d585f9272f |
@@ -12,3 +12,6 @@ deploy.env
|
||||
# Screenshot tooling: bytecode, and database dumps that hold production data
|
||||
__pycache__/
|
||||
*.dump
|
||||
|
||||
# Output of scripts/check/site.py
|
||||
.check/
|
||||
|
||||
@@ -10,9 +10,12 @@ get started.
|
||||
## Project Purpose
|
||||
|
||||
Potential users land here and need to answer three questions in under 10 seconds:
|
||||
1. What is this?
|
||||
2. Is it for me?
|
||||
3. How do I try it?
|
||||
1. What is this? — Control instead of drift: define the desired state once,
|
||||
netOrk finds every deviation and puts it back.
|
||||
2. Is it for me? — IT departments in small and mid-sized companies with mixed
|
||||
hardware (service providers and IT support are secondary).
|
||||
3. What does it cost? — netOrk is free; a licence adds vulnerability data and
|
||||
updates (`/pricing`).
|
||||
|
||||
Everything on the site should serve those three questions.
|
||||
|
||||
@@ -74,13 +77,19 @@ exactly as netOrk's `ui/src/components/ui.tsx` does.
|
||||
|
||||
## Design Rules (summary — full detail in docs/DESIGN.md)
|
||||
|
||||
- **Dark theme only.** Background `bg-slate-950`. Cards `bg-slate-900`.
|
||||
- **Accent color:** `sky-500` / `sky-600` for CTAs, links, highlights.
|
||||
- **No light mode toggle.** Ever.
|
||||
- Font stack: system default (Tailwind sans). No Google Fonts.
|
||||
- All interactive elements use `transition-colors` — no layout shifts.
|
||||
- Screenshots/mockups of the actual app use a `border border-slate-700 rounded-xl
|
||||
overflow-hidden` wrapper to frame them against the dark background.
|
||||
- **Light pages, dark product.** Pages are `paper` with `ink`; the only dark
|
||||
surfaces are real netOrk screenshots and code, on the `night` stage.
|
||||
- One accent, `accent` (sky-700), for links, eyebrows and focus. Buttons are ink.
|
||||
- Font: Inter Variable, self-hosted via `@fontsource-variable/inter`. No Google
|
||||
Fonts, no request to any other origin.
|
||||
- Layout from `src/components/ui.tsx`: split sections, ruled lists, numbered
|
||||
steps, bands. No card grids, no icon tiles, no centred text blocks.
|
||||
- Motion: `transition-colors` only — no layout shifts.
|
||||
- **Screenshots are real.** Taken from the anonymised demo copy with
|
||||
`scripts/screenshots/capture.py`, cropped to what the text talks about, at most
|
||||
four on the site. No mockups, no edited data, no clicks that fake a state.
|
||||
- **Less text.** Word budgets per page are in docs/PAGES.md and checked by
|
||||
`scripts/check/site.py`.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +117,9 @@ netork-website/
|
||||
|
||||
## Tone of Voice
|
||||
|
||||
- Direct and technical — audience is engineers, not executives.
|
||||
- Direct and technical — audience is the admins of an IT department, not executives.
|
||||
- One message: control instead of drift. Everything else supports it.
|
||||
- No marketing fluff ("revolutionize", "empower", "seamless").
|
||||
- Show, don't tell — a screenshot or code block beats three sentences of prose.
|
||||
- German is fine for internal docs; the website copy is in **English**.
|
||||
- Show, don't tell — a real screenshot beats three sentences of prose.
|
||||
- Website copy exists in **English and German** (German addresses readers with
|
||||
"ihr"); the language follows the browser until the visitor chooses.
|
||||
|
||||
@@ -1,304 +1,78 @@
|
||||
# netOrk — Visual Identity & Design System
|
||||
# netOrk website — design system
|
||||
|
||||
This document defines the visual identity of netOrk and must be followed
|
||||
exactly when building the website. The goal is zero visual discontinuity
|
||||
between the product UI and the marketing site.
|
||||
**Light pages, dark product.** The site is calm paper with ink; the only dark
|
||||
surfaces are real screenshots of netOrk and code, set on a "stage". References:
|
||||
Linear/Vercel for precision, Tailscale/Netbird for friendliness.
|
||||
|
||||
---
|
||||
Tokens live in `tailwind.config.js`, building blocks in `src/components/ui.tsx`.
|
||||
If a page needs something that is not there, it probably needs less instead.
|
||||
|
||||
## Logo Assets
|
||||
## Colour
|
||||
|
||||
The logo file is in `public/` — use it directly, do not recreate.
|
||||
|
||||
| File | Format | Size | Use |
|
||||
|---|---|---|---|
|
||||
| `public/logo.png` | PNG | 1024×1024, RGBA | Nav logo, OG image, hero, press kit, favicon fallback |
|
||||
|
||||
### Usage in `<img>` (nav, hero)
|
||||
|
||||
```tsx
|
||||
<img src="/logo.png" alt="netOrk" className="h-8 w-8" />
|
||||
```
|
||||
|
||||
For the nav, pair it with the wordmark:
|
||||
|
||||
```tsx
|
||||
<a href="/" className="flex items-center gap-2.5">
|
||||
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
|
||||
<span className="font-semibold text-slate-100 tracking-tight">
|
||||
net<span className="text-sky-400">Ork</span>
|
||||
</span>
|
||||
</a>
|
||||
```
|
||||
|
||||
Use `net<span class="text-sky-400">Ork</span>` consistently — the `Ork` part
|
||||
in sky-400 ties the wordmark to the accent color.
|
||||
|
||||
### `<head>` references
|
||||
|
||||
```html
|
||||
<link rel="icon" href="/logo.png" />
|
||||
<meta property="og:image" content="/logo.png" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
|
||||
**The website looks like a dark-mode dev tool, not a SaaS landing page.**
|
||||
No gradients, no floating orbs, no animated hero blobs. The aesthetic is
|
||||
deliberate, minimal, and technical — consistent with the product itself.
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
All colors are Tailwind CSS v3 classes. Do not use hex values directly —
|
||||
always use Tailwind class names to stay consistent.
|
||||
|
||||
### Backgrounds
|
||||
|
||||
| Layer | Class | Usage |
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| Page / outermost | `bg-slate-950` | Body, full-bleed sections |
|
||||
| Card / panel | `bg-slate-900` | Content cards, code blocks, feature boxes |
|
||||
| Elevated element | `bg-slate-800` | Hover states, dropdowns, table rows on hover |
|
||||
| Border | `border-slate-700` | Between sections, card outlines |
|
||||
| Subtle border | `border-slate-800` | Inside cards, dividers |
|
||||
| `paper` / `paper-2` | `#FAFAF9` / `#F3F3F0` | page / band |
|
||||
| `line` / `line-strong` | `#E6E6E3` / `#D4D4D0` | hairlines |
|
||||
| `ink` / `ink-soft` / `ink-muted` / `ink-faint` | `#0E1116` … `#9AA0A8` | headings / body / secondary / quiet |
|
||||
| `night` | `#020617` | screenshot and code stage — the netOrk UI's own background |
|
||||
| `accent` (`hover`, `soft`) | `#0369A1` | links, eyebrows, numbers, focus ring (5.7:1 on paper) |
|
||||
| `drift` / `sync` | `#B45309` / `#15803D` | status: deviates / matches; never the only signal |
|
||||
|
||||
### Text
|
||||
No gradients, no glow, no second accent. Buttons are ink, not accent.
|
||||
|
||||
| Role | Class |
|
||||
## Type
|
||||
|
||||
Inter Variable, self-hosted through `@fontsource-variable/inter` (bundled by
|
||||
Vite; the site makes no request to anyone else). Scale:
|
||||
|
||||
| Class | Use |
|
||||
|---|---|
|
||||
| Primary | `text-slate-100` |
|
||||
| Secondary / muted | `text-slate-400` |
|
||||
| Tertiary / placeholder | `text-slate-500` |
|
||||
| Accent (interactive) | `text-sky-400` |
|
||||
| Danger | `text-red-400` |
|
||||
| `text-display` | the home headline only |
|
||||
| `text-h1` | one per page, in `PageHeader` |
|
||||
| `text-h2` | section headings |
|
||||
| `text-h3` | row terms, plan names |
|
||||
| `text-lead` | the paragraph under a heading |
|
||||
| `text-eyebrow` + `uppercase text-accent` | the small line above a heading |
|
||||
|
||||
### Accent (Interactive / CTA)
|
||||
Everything is left-aligned. Headings balance and hyphenate (`<html lang>` is
|
||||
set per language). Text columns stay within `max-w-measure` (38rem).
|
||||
|
||||
| State | Class |
|
||||
|---|---|
|
||||
| Default button | `bg-sky-600 text-white` |
|
||||
| Hover | `hover:bg-sky-500` |
|
||||
| Link / inline | `text-sky-400 hover:text-sky-300` |
|
||||
| Active indicator | `text-sky-400` |
|
||||
| Focus ring | `focus:ring-sky-500` |
|
||||
## Layout
|
||||
|
||||
### Semantic Colors
|
||||
- Container `max-w-page` (72rem), `px-5 sm:px-8`. Sections `py-20 md:py-28`.
|
||||
- **Split**: heading on the left five columns, content on the right. The
|
||||
default section.
|
||||
- **RuleList**: rows divided by hairlines, term and body; one or two columns.
|
||||
This replaces every card grid.
|
||||
- **Steps**: numbered rows (`01`, `02`, `03`) in mono accent.
|
||||
- **Band**: `bg-paper-2` with hairlines, for the hardware strip and the closing
|
||||
call to action (`CtaBand`).
|
||||
- **Stage**: `bg-night`, `rounded-2xl`, `shadow-stage` — screenshots (`Shot`)
|
||||
and code (`CodeBlock`).
|
||||
|
||||
| Meaning | Color |
|
||||
|---|---|
|
||||
| Success / active | `text-green-400`, `bg-green-500/20` |
|
||||
| Warning / caution | `text-yellow-400`, `bg-yellow-500/20` |
|
||||
| Danger / error | `text-red-400`, `bg-red-500/20` |
|
||||
| Info / neutral | `text-blue-400`, `bg-blue-500/20` |
|
||||
Not used: icon tiles, pills for names, cards, centred text blocks, fake browser
|
||||
windows, emoji. Motion is `transition-colors` only.
|
||||
|
||||
### Status Badge Pattern
|
||||
## Screenshots
|
||||
|
||||
```tsx
|
||||
// Active / online
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
|
||||
active
|
||||
</span>
|
||||
Only real screenshots of netOrk, from the anonymised demo copy
|
||||
(`scripts/demo`), taken by `scripts/screenshots/capture.py` and published as
|
||||
WebP in `public/screenshots/`. Their sizes are written to
|
||||
`src/data/screenshots.json`, which `Shot` reads.
|
||||
|
||||
// Offline
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-red-500/20 text-red-400">
|
||||
offline
|
||||
</span>
|
||||
- At most four different images on the site. Each is cropped to the one thing
|
||||
the text next to it talks about (`clip` or `element` in `shots.py`).
|
||||
- Cropping shows less of a real screen; it never changes what is on it. No
|
||||
edited data, no clicks that fake a state, no mockups.
|
||||
- A crop that does not read on a phone gets a `-narrow` variant (`Shot narrow=`).
|
||||
- Every image has an alt text and a caption that says what is true in it.
|
||||
|
||||
// Warning
|
||||
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">
|
||||
warning
|
||||
</span>
|
||||
```
|
||||
## Wordmark
|
||||
|
||||
---
|
||||
Text only: `net<span class="text-accent">Ork</span>` in ink. `public/logo.png`
|
||||
is the favicon and OG image; it does not sit well on a light background.
|
||||
|
||||
## Typography
|
||||
## Glossary marks
|
||||
|
||||
Font stack: Tailwind default sans-serif (`font-sans`). **No Google Fonts.**
|
||||
The product uses system fonts; the website must match.
|
||||
|
||||
| Element | Classes |
|
||||
|---|---|
|
||||
| Hero heading | `text-4xl md:text-6xl font-bold text-slate-100 leading-tight` |
|
||||
| Section heading | `text-2xl md:text-3xl font-bold text-slate-100` |
|
||||
| Subsection heading | `text-xl font-semibold text-slate-200` |
|
||||
| Body text | `text-base text-slate-400 leading-relaxed` |
|
||||
| Small / label | `text-sm text-slate-400` |
|
||||
| Tiny / tag | `text-xs font-medium text-slate-500` |
|
||||
| Code / monospace | `font-mono text-sky-400` |
|
||||
| Accent text | `text-sky-400` |
|
||||
|
||||
---
|
||||
|
||||
## Spacing & Layout
|
||||
|
||||
- Max content width: `max-w-7xl mx-auto px-6`
|
||||
- Section padding: `py-24` (desktop), `py-16` (mobile)
|
||||
- Card padding: `p-6`
|
||||
- Gap between grid items: `gap-6` or `gap-8`
|
||||
- All layouts are mobile-first; use `md:` and `lg:` breakpoints.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Primary CTA Button
|
||||
|
||||
```tsx
|
||||
<a
|
||||
href="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg
|
||||
bg-sky-600 hover:bg-sky-500 text-white font-medium
|
||||
transition-colors"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
```
|
||||
|
||||
### Secondary / Ghost Button
|
||||
|
||||
```tsx
|
||||
<a
|
||||
href="/features"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg
|
||||
border border-slate-700 hover:border-slate-500
|
||||
text-slate-300 hover:text-slate-100
|
||||
transition-colors"
|
||||
>
|
||||
See all features
|
||||
</a>
|
||||
```
|
||||
|
||||
### Feature Card
|
||||
|
||||
```tsx
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center
|
||||
rounded-lg bg-sky-600/10">
|
||||
{/* Heroicon SVG, className="h-5 w-5 text-sky-400" */}
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-100">Feature name</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
Description of the feature in one to three sentences.
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Screenshot Frame
|
||||
|
||||
App screenshots must be wrapped in this frame to integrate naturally
|
||||
against the dark background:
|
||||
|
||||
```tsx
|
||||
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
||||
<img src="/screenshots/devices.webp" alt="Device inventory" className="w-full" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Only real screenshots of the running application.** No JSX mockups or
|
||||
drawn imitations of the UI. They come from an anonymized demo copy of a real
|
||||
installation and are taken with `scripts/screenshots/capture.py` (see
|
||||
`scripts/demo/README.md`), published as WebP in `public/screenshots/`.
|
||||
`Screenshot` in `src/pages/Home.tsx` is the frame.
|
||||
|
||||
Optionally add a browser chrome header above the image:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||
<span className="ml-4 text-xs text-slate-500 font-mono">netork / devices</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Code Block
|
||||
|
||||
```tsx
|
||||
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6
|
||||
font-mono text-sm text-slate-300 overflow-x-auto">
|
||||
<code>{`bash scripts/deploy.sh 192.168.1.1`}</code>
|
||||
</pre>
|
||||
```
|
||||
|
||||
### Driver / Integration Badge
|
||||
|
||||
```tsx
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full
|
||||
border border-slate-700 bg-slate-900
|
||||
text-xs font-medium text-slate-300">
|
||||
OpenWRT
|
||||
</span>
|
||||
```
|
||||
|
||||
### Section Divider
|
||||
|
||||
```tsx
|
||||
<div className="border-t border-slate-800" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
- Sticky top nav: `sticky top-0 z-10 bg-slate-900/80 backdrop-blur
|
||||
border-b border-slate-800`
|
||||
- Logo: left-aligned. Product name in `font-semibold text-slate-100`,
|
||||
optionally prefixed with a small icon.
|
||||
- Nav links: `text-sm text-slate-400 hover:text-slate-100 transition-colors`
|
||||
- Active link: `text-slate-100`
|
||||
- CTA in nav: small primary button `px-4 py-1.5 text-sm`
|
||||
|
||||
---
|
||||
|
||||
## Animations & Transitions
|
||||
|
||||
- **Hover states:** always `transition-colors` (not `transition-all`).
|
||||
- **No JavaScript animations** on initial page load — no entrance animations,
|
||||
no scroll-triggered reveals via IntersectionObserver.
|
||||
- Scroll behavior: `scroll-smooth` on `<html>` for anchor links.
|
||||
- No parallax, no floating elements, no auto-playing videos.
|
||||
|
||||
---
|
||||
|
||||
## Logo / Wordmark
|
||||
|
||||
The netOrk wordmark uses the following convention in the product:
|
||||
- Lowercase `n`, uppercase `O`: **netOrk**
|
||||
- Monospace context: `font-mono text-sky-400`
|
||||
- Heading context: `font-bold text-slate-100` with `Ork` potentially in
|
||||
`text-sky-400` if desired for emphasis
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
Use Heroicons (inline SVG). Sizes:
|
||||
- Feature card icons: `h-5 w-5`
|
||||
- Nav / button icons: `h-4 w-4`
|
||||
- Hero / large decorative: `h-8 w-8` or `h-10 w-10`
|
||||
|
||||
All icons: `text-sky-400` in feature contexts, `text-slate-400` in
|
||||
secondary/muted contexts.
|
||||
|
||||
---
|
||||
|
||||
## tailwind.config.js
|
||||
|
||||
No custom theme extensions needed. The default Tailwind v3 slate + sky
|
||||
palette covers everything. The config only needs content paths:
|
||||
|
||||
```js
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
```
|
||||
Terms from `src/glossary/terms.ts` get a dotted underline and a dark tooltip
|
||||
(`linkify`). Not on the homepage — short copy there stays unmarked.
|
||||
|
||||
@@ -1,442 +1,83 @@
|
||||
# netOrk Website — Page Structure & Content Plan
|
||||
# netOrk website — pages
|
||||
|
||||
This document defines every page of the website: its purpose, section
|
||||
structure, and draft copy. Use this as the brief for implementation.
|
||||
**Audience:** IT departments in small and mid-sized companies — a small team,
|
||||
mixed hardware (OPNsense, HPE ProCurve/Aruba, TP-Link JetStream, OpenWrt,
|
||||
Proxmox, Linux), NIS2 on the agenda. Service providers and IT support are
|
||||
secondary and get their own page each.
|
||||
|
||||
---
|
||||
**One message:** control instead of drift — define the desired state once;
|
||||
netOrk notices every change, shows what deviates and puts it back.
|
||||
|
||||
## Page Overview
|
||||
**One goal:** buy a licence. netOrk itself is free; the licence adds
|
||||
vulnerability data and image updates.
|
||||
|
||||
| Route | Page | Priority |
|
||||
All copy is in `src/i18n/translations.ts`, English and German (German uses
|
||||
"ihr"). The language follows the browser until someone chooses. Every claim
|
||||
must be backed by `docs/PRODUCT.md`; automatic fixing exists for access point
|
||||
profiles, so the site says "every deviation", never "every device fixes itself".
|
||||
|
||||
## Word budgets (English, text in `<main>`)
|
||||
|
||||
`scripts/check/site.py` counts them; Home over budget fails the check.
|
||||
|
||||
| Page | Route | Budget |
|
||||
|---|---|---|
|
||||
| `/` | Landing (Home) | P0 — build first |
|
||||
| `/features` | Full feature list | P1 |
|
||||
| `/drivers` | Supported devices | P1 |
|
||||
| `/docs/getting-started` | Installation guide | P1 |
|
||||
| `/roadmap` | Roadmap — planned + under consideration | P1 |
|
||||
| `/nis2` | NIS2 landing page — Art. 21 mapping, evidence, roadmap | P1 |
|
||||
| `/docs/architecture` | Technical overview | P2 |
|
||||
| `/plugins` | Plugin system | P2 |
|
||||
|
||||
---
|
||||
|
||||
## `/` — Landing Page
|
||||
|
||||
### Section 1 — Hero
|
||||
|
||||
**Purpose:** Answer "what is this?" in 5 seconds.
|
||||
|
||||
**Layout:** Full-width, centered. Heading + subheading + two CTAs + hero
|
||||
screenshot below.
|
||||
|
||||
**Heading:**
|
||||
```
|
||||
Network orchestration
|
||||
for heterogeneous infrastructure.
|
||||
```
|
||||
(`text-slate-100` for first line, second line in `text-sky-400` or keep
|
||||
both `text-slate-100` — designer decides.)
|
||||
|
||||
**Subheading:**
|
||||
```
|
||||
netOrk discovers, monitors, and manages your routers, switches, access
|
||||
points, firewalls, and servers from a single UI — regardless of vendor.
|
||||
No SaaS dependency. Runs on your infrastructure.
|
||||
```
|
||||
|
||||
**CTAs:**
|
||||
- Primary: `Get started →` → `/docs/getting-started`
|
||||
- Secondary: `View features` → `/features`
|
||||
|
||||
**Hero visual:** Full-width screenshot of the device inventory page
|
||||
(dark UI visible, framed with the browser chrome component from DESIGN.md).
|
||||
|
||||
---
|
||||
|
||||
### Section 2 — Problem Statement
|
||||
|
||||
**Purpose:** Make the pain relatable.
|
||||
|
||||
**Layout:** Single centered paragraph or short 3-column stat row.
|
||||
|
||||
**Copy:**
|
||||
```
|
||||
Managing a mixed network means juggling a different admin UI for every
|
||||
vendor — one for OPNsense, one for HP ProCurve, one for OpenWRT, one for
|
||||
Proxmox. Config changes happen directly on devices with no audit trail.
|
||||
You find out something drifted when it breaks.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Section 3 — Core Capabilities (3-up)
|
||||
|
||||
**Purpose:** Communicate the three main things netOrk does.
|
||||
|
||||
**Layout:** 3 columns, each with icon + heading + 2–3 sentences.
|
||||
|
||||
**Card 1 — Discover & Inventory**
|
||||
- Icon: `MagnifyingGlassIcon`
|
||||
- Heading: `Discover everything on your network`
|
||||
- Copy: `ICMP sweep, SNMP scan, and HTTP probing find devices before you
|
||||
add them. Fingerprinting identifies vendor and platform automatically.
|
||||
Adopt results into your inventory with a single click.`
|
||||
|
||||
**Card 2 — Monitor & Alert**
|
||||
- Icon: `ChartBarIcon` or `SignalIcon`
|
||||
- Heading: `Poll device state continuously`
|
||||
- Copy: `Every device is polled on a configurable interval via NAPALM.
|
||||
Interface status, ARP tables, DHCP leases, VLAN membership, Docker
|
||||
containers, and SNMP health metrics — all in one place.`
|
||||
|
||||
**Card 3 — Configure & Enforce**
|
||||
- Icon: `WrenchScrewdriverIcon`
|
||||
- Heading: `Detect drift. Fix it.`
|
||||
- Copy: `Define desired state in netOrk. On every poll, device config is
|
||||
compared against it. Drifted devices get a warning; a one-click fix
|
||||
stream applies the correction and shows you live SSH output.`
|
||||
|
||||
---
|
||||
|
||||
### Section 4 — Driver Grid
|
||||
|
||||
**Purpose:** Show breadth of vendor support.
|
||||
|
||||
**Layout:** Centered heading + wrapping badge grid.
|
||||
|
||||
**Heading:** `Works with your hardware`
|
||||
|
||||
**Subheading:**
|
||||
```
|
||||
netOrk ships with custom NAPALM drivers for 11 device types, plus all
|
||||
built-in NAPALM drivers. New drivers follow a documented registration
|
||||
pattern.
|
||||
```
|
||||
|
||||
**Badge list** (see `docs/PRODUCT.md` — driver table):
|
||||
OpenWRT, OPNsense, Proxmox VE, Linux, HP ProCurve / Aruba, TP-Link Jetstream,
|
||||
Netgear, Fritz!Box, Zyxel, OpenMediaVault, Sonos,
|
||||
Cisco IOS, Arista EOS, Juniper JunOS
|
||||
|
||||
Each badge uses the `Driver / Integration Badge` component from DESIGN.md.
|
||||
|
||||
---
|
||||
|
||||
### Section 5 — Screenshot Walkthrough (alternating)
|
||||
|
||||
**Purpose:** Show the UI concretely. Six alternating image + text rows, each a
|
||||
real screenshot from `scripts/screenshots/shots.py`. Copy lives in
|
||||
`home.screenshot1`–`screenshot6` in `src/i18n/translations.ts`.
|
||||
|
||||
| Row | Heading | Screenshot |
|
||||
|---|---|---|
|
||||
| 1 | Device detail at a glance | `device-detail` — an access point, Networking → Interfaces |
|
||||
| 2 | Intent-based VLAN and SSID management | `vlans` — VLAN list by site |
|
||||
| 3 | A security assessment for every device | `device-security` — a server, Security → Assessment |
|
||||
| 4 | One triage queue, decisions that hold | `vulnerabilities` — the triage queue |
|
||||
| 5 | Dashboards you actually build | `dashboard` — the home dashboard |
|
||||
| 6 | Service checks every minute | `service-checks` — Network → Service Checks |
|
||||
|
||||
Text sits left on odd rows and right on even rows; on mobile the text always
|
||||
comes first.
|
||||
|
||||
---
|
||||
|
||||
### Section 5b — NIS2
|
||||
|
||||
**Purpose:** Hook for organizations evaluating netOrk in a NIS2 context.
|
||||
|
||||
**Layout:** Left column — label + Art. 21 mapping list. Right column — screenshot of the audit log (`audit-log`), the evidence the list refers to.
|
||||
|
||||
**Label (eyebrow):** `NIS2 · Art. 21` (sky-500, uppercase, tracking-widest)
|
||||
|
||||
**Heading:** `Evidence, not paperwork.`
|
||||
|
||||
**Copy:**
|
||||
```
|
||||
NIS2 Art. 21 mandates asset inventory, patch management, access control,
|
||||
and audit trails as baseline technical measures. netOrk doesn't bolt on a
|
||||
compliance layer — these are its day-to-day outputs.
|
||||
```
|
||||
|
||||
**Art. 21 mapping (4 rows, icon = monospace article ref in sky-500):**
|
||||
- Art. 21 (2e) → Patch & vulnerability management — Per-device update status, Wazuh CVE counts by severity
|
||||
- Art. 21 (2h) → Asset management & access control — Full device inventory, RBAC with four roles, complete audit log
|
||||
- Art. 21 (2a) → Risk analysis baseline — Config drift detection, SNMP health metrics, security agent coverage
|
||||
- Art. 21 (2b) → Incident detection — Wazuh alert history, CrowdSec decisions, Graylog syslog per device
|
||||
|
||||
**Mock UI (right column):** `MockCompliance` — per-site checklist with ✓/⚠ rows, each showing label + detail stat. Label: `netork.local / compliance / HQ`.
|
||||
|
||||
---
|
||||
|
||||
### Section 6 — Plugin System (brief)
|
||||
|
||||
**Purpose:** Signal extensibility without going deep.
|
||||
|
||||
**Layout:** Dark card, left-aligned.
|
||||
|
||||
**Heading:** `Built to extend`
|
||||
|
||||
**Copy:**
|
||||
```
|
||||
Integrations (Wazuh, Graylog, CrowdSec, apt-cacher-ng) are plugins
|
||||
that register into the plugin system — they can be enabled or disabled
|
||||
per deployment without code changes. Adding a new integration follows
|
||||
a documented pattern with a hook bus, typed metadata, and a plugin
|
||||
registry.
|
||||
```
|
||||
|
||||
**CTA:** `Plugin system docs →` → `/plugins`
|
||||
|
||||
---
|
||||
|
||||
### Section 7 — Deployment (quick)
|
||||
|
||||
**Purpose:** Answer "how do I run this?" without going into detail.
|
||||
|
||||
**Layout:** Code block + short description.
|
||||
|
||||
**Heading:** `Self-hosted. One command.`
|
||||
|
||||
**Copy:**
|
||||
```
|
||||
netOrk runs in Docker Compose. Five containers: API, two worker pools,
|
||||
a Beat scheduler, and an nginx UI server. No external dependencies beyond
|
||||
Redis and PostgreSQL.
|
||||
```
|
||||
|
||||
**Code block:**
|
||||
```bash
|
||||
# Clone + configure
|
||||
git clone https://gitea.example.com/netork/netork.git
|
||||
cp .env.example .env
|
||||
# edit .env (DB URL, Redis password, secret key)
|
||||
|
||||
# Deploy
|
||||
bash scripts/deploy.sh 192.168.1.10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Section 8 — CTA Footer
|
||||
|
||||
**Layout:** Centered, full-width dark section.
|
||||
|
||||
**Heading:** `Start managing your network.`
|
||||
|
||||
**CTA:** `Read the docs →` → `/docs/getting-started`
|
||||
|
||||
---
|
||||
|
||||
## `/features` — Full Feature List
|
||||
|
||||
**Purpose:** Comprehensive reference for people who want to evaluate in depth.
|
||||
|
||||
**Layout:** Vertical list of expandable sections (or just long-scroll with
|
||||
sticky section nav). One section per capability area.
|
||||
|
||||
**Sections** (map directly to feature list in `docs/PRODUCT.md`):
|
||||
1. Device Management
|
||||
2. Discovery
|
||||
3. VM Provisioning
|
||||
4. Supported Drivers (full table)
|
||||
5. Networking & Inventory
|
||||
6. Configuration Management & Drift
|
||||
7. Configuration Automation (Ansible)
|
||||
8. Scheduled Operations
|
||||
9. Satellite Deployments
|
||||
10. Monitoring & Health
|
||||
11. Dashboards
|
||||
12. Security Integrations
|
||||
13. DNS Management
|
||||
14. RADIUS Management
|
||||
15. Access Control (RBAC)
|
||||
16. NetBox Sync
|
||||
17. Compliance & Audit (NIS2)
|
||||
18. Developer Experience
|
||||
|
||||
Each section: `text-xl font-semibold text-slate-200` heading +
|
||||
feature items as a clean list with `text-slate-400` body.
|
||||
|
||||
---
|
||||
|
||||
## `/drivers` — Supported Devices
|
||||
|
||||
**Purpose:** One-page reference for "does netOrk support my device?"
|
||||
|
||||
**Layout:** Full table + short description per driver.
|
||||
|
||||
**Table columns:** Driver name | Device type | Capabilities | Status
|
||||
|
||||
**Capabilities** — checkmarks or tags for:
|
||||
- `get_facts` `get_interfaces` `get_lldp` `get_vlans` `get_ssids`
|
||||
`get_health_metrics` `get_docker` `scheduled_reboot` `config_push`
|
||||
|
||||
**Status:** `stable` / `beta` / `community` as a badge.
|
||||
|
||||
---
|
||||
|
||||
## `/docs/getting-started` — Installation
|
||||
|
||||
**Purpose:** Get someone from zero to a running instance.
|
||||
|
||||
**Sections:**
|
||||
|
||||
1. **Prerequisites**
|
||||
- Docker + Docker Compose
|
||||
- A PostgreSQL instance (or use the bundled profile)
|
||||
- Redis
|
||||
- A Linux host reachable by SSH from the server
|
||||
|
||||
2. **Quick start**
|
||||
```bash
|
||||
git clone ...
|
||||
cp .env.example .env
|
||||
# Edit .env
|
||||
bash scripts/deploy.sh <server-ip>
|
||||
```
|
||||
|
||||
3. **First run**
|
||||
- Navigate to `http://<server-ip>`
|
||||
- Complete the setup wizard (creates admin user)
|
||||
- Add your first device
|
||||
|
||||
4. **Adding a device**
|
||||
- Fill in hostname/IP, driver, and credentials
|
||||
- Click Poll to verify connectivity
|
||||
- Set a poll interval for continuous monitoring
|
||||
|
||||
5. **Next steps**
|
||||
- Configure NetBox sync
|
||||
- Set up Wazuh integration
|
||||
- Enable scheduled reboots for OpenWRT APs
|
||||
|
||||
---
|
||||
|
||||
## `/roadmap` — Roadmap
|
||||
|
||||
**Purpose:** Show what's being built and what's under consideration. Signal NIS2 investment clearly.
|
||||
|
||||
**Layout:** Page header + two vertical groups ("Planned" / "Under consideration"), each a list of items.
|
||||
|
||||
**NIS2 badge:** `NIS2` monospace tag (sky-500/10 bg, sky-400 text, sky-500/20 border) inline next to item title.
|
||||
|
||||
**Intro copy:**
|
||||
```
|
||||
What's being built and what's being evaluated. Items tagged NIS2 directly
|
||||
address NIS2 Art. 21 technical baseline requirements.
|
||||
```
|
||||
|
||||
**Planned items (NIS2-tagged):**
|
||||
- CVE tracking per device — NVD / OSV cross-reference
|
||||
- Compliance dashboard — per-site Art. 21 checklist view
|
||||
|
||||
**Planned items (general):**
|
||||
- Webhook engine — outbound events with HMAC signing
|
||||
- Live job log streaming — WebSocket for all long-running tasks
|
||||
- NetBox sync — manual trigger + status view
|
||||
|
||||
**Under consideration (NIS2-tagged):**
|
||||
- Incident workflow — structured record + NIS2 Art. 23 Fristen-Tracker
|
||||
|
||||
**Under consideration (general):**
|
||||
- mDNS scanner — media device discovery
|
||||
- Prometheus + Grafana — metrics and dashboards
|
||||
- Kubernetes Helm chart
|
||||
|
||||
---
|
||||
|
||||
## `/docs/architecture` — Technical Overview
|
||||
|
||||
**Purpose:** Give engineers the mental model before they look at code.
|
||||
|
||||
**Content:** Essentially the one-paragraph summary from `docs/PRODUCT.md`
|
||||
expanded into a readable overview with the architecture diagram (ASCII or SVG).
|
||||
|
||||
**Sections:**
|
||||
1. Overview (request → FastAPI → DB / Celery worker)
|
||||
2. Driver system (NAPALM + custom drivers + registry)
|
||||
3. Task queues (which queue does what)
|
||||
4. Plugin system (register → hook bus → router mount)
|
||||
5. Data model (UUID PKs, JSONB snapshots, intent-vs-state)
|
||||
|
||||
---
|
||||
|
||||
## `/plugins` — Plugin System
|
||||
|
||||
**Purpose:** Explain extensibility to potential contributors.
|
||||
|
||||
**Sections:**
|
||||
1. What is a plugin? (metadata, router, tasks, hooks)
|
||||
2. Built-in plugins (Wazuh, Graylog, CrowdSec, apt-cacher)
|
||||
3. Writing a plugin (step-by-step with code snippets)
|
||||
4. Hook bus (fire / call / transform)
|
||||
5. Plugin registry and enable/disable
|
||||
|
||||
---
|
||||
|
||||
## `/for/*` — Persona Pages
|
||||
|
||||
**Purpose:** Answer "is this for me?" from the perspective of a specific
|
||||
buyer/user, instead of one generic homepage pitch. Reachable via the "Für
|
||||
wen" / "Who it's for" nav dropdown.
|
||||
|
||||
**Shared layout:** hero (icon + heading + sub) → "Your day today" pain-point
|
||||
cards (persona-specific, concrete workflow friction) → feature-callout cards
|
||||
(only shipped capabilities, cited from `docs/PRODUCT.md`) → CTA block linking
|
||||
to `/docs/getting-started`. Same card/section classes as `/plugins`.
|
||||
|
||||
- **`/for/it-department`** — core admin/engineer audience. Pain points:
|
||||
per-vendor admin UIs, no single inventory view, undocumented config
|
||||
changes, manual SSH just to check state. Features: Device Management,
|
||||
config drift + one-click fix, Git-backed config history, Ansible
|
||||
automation, VM Provisioning, Dashboards. Plus a short supported-drivers
|
||||
strip linking to `/drivers`.
|
||||
- **`/for/it-support`** — day-to-day operators, less config depth. Pain
|
||||
points: "is it up right now?", repeated manual reboots, no change record,
|
||||
full admin access for one ticket. Features: warning system + dashboard
|
||||
widget, one-click Ack, Wake-on-LAN, scheduled reboots/updates, filterable
|
||||
audit log + export, roles scoped below engineer level.
|
||||
- **`/for/msp`** — managed service providers, strongest standalone buying
|
||||
case. Pain points: unreachable client sites, no cross-client view, proving
|
||||
what was done, client data in someone else's cloud. Features: Satellite
|
||||
Deployments, automatic routing around unreachable sites (with the honest
|
||||
caveat that SNMP metrics + WebSSH still need direct reach), audit trail as
|
||||
client-facing evidence, per-technician custom roles (**not** phrased as
|
||||
per-site RBAC — netOrk's roles are global permission sets, not
|
||||
site-scoped), self-hosted/no per-seat SaaS.
|
||||
|
||||
The existing `/nis2` page (security/compliance persona) is linked from the
|
||||
same dropdown rather than duplicated.
|
||||
|
||||
---
|
||||
|
||||
## Global Layout
|
||||
|
||||
### Navigation (all pages)
|
||||
|
||||
```
|
||||
[ netOrk ] Features Drivers Docs ▾ Für wen ▾ Plugins Roadmap [ Get started ]
|
||||
```
|
||||
|
||||
`Docs ▾`: Getting Started / Architecture / NIS2 Compliance / Glossary
|
||||
`Für wen ▾`: IT Department / IT Support / MSP / NIS2 Compliance (reuses the Docs dropdown's NIS2 link/label)
|
||||
|
||||
### Footer
|
||||
|
||||
```
|
||||
netOrk — self-hosted network orchestration
|
||||
|
||||
Links: Resources: Legal:
|
||||
Features Getting Started MIT License
|
||||
Drivers Architecture Privacy (none collected)
|
||||
Plugins Changelog
|
||||
Roadmap NIS2
|
||||
Glossary
|
||||
For IT Departments
|
||||
For IT Support
|
||||
For MSPs
|
||||
```
|
||||
|
||||
Footer background: `bg-slate-900 border-t border-slate-800`
|
||||
Footer text: `text-sm text-slate-500`
|
||||
| Home | `/` | 400 |
|
||||
| Pricing | `/pricing` | 300 |
|
||||
| NIS2 | `/nis2` | 550 |
|
||||
| Plugins | `/plugins` | 450 |
|
||||
| Persona ×3 | `/for/it-department`, `/for/it-support`, `/for/msp` | 320 |
|
||||
| Getting started | `/docs/getting-started` | 80 |
|
||||
| Glossary, Impressum, Datenschutz | `/glossary`, `/impressum`, `/datenschutz` | — |
|
||||
|
||||
Old routes redirect (in `App.tsx` and as 301 in `nginx.conf`): `/features` →
|
||||
`/#included`, `/drivers` → `/#hardware`, `/roadmap` and `/docs/architecture` → `/`.
|
||||
|
||||
## Home
|
||||
|
||||
1. **Hero** — "Control instead of drift." The drift comparison of an access
|
||||
point (`drift`, `drift-narrow` on phones) with a caption saying what is in it.
|
||||
2. **How it works** (`#how`) — Define → Detect → Fix, three steps.
|
||||
3. **Hardware** (`#hardware`) — two lines of names: "desired state and fixes"
|
||||
and "inventory and monitoring"; a note on the untested NAPALM drivers.
|
||||
4. **Vulnerabilities, with a licence** — the triage queue (`vulnerabilities`),
|
||||
link to Pricing.
|
||||
5. **Also in the box** (`#included`) — eight terms, one short line each; a NIS2
|
||||
row with a link.
|
||||
6. **Closing band** — "netOrk is free. The licence adds the data."
|
||||
|
||||
## Pricing
|
||||
|
||||
The free core as one row, then Starter / Pro / Enterprise, each "on request"
|
||||
with a "Buy a licence" button to the licence portal (`src/data/plans.ts`). Plan
|
||||
differences come from the licence server's plan defaults; the KB request limit
|
||||
stays off the page until it has a unit. Four questions below, and the note that
|
||||
there is no public installer yet.
|
||||
|
||||
## Persona pages
|
||||
|
||||
One template (`Persona.tsx`): header, three problems, five ways netOrk helps
|
||||
(desired state and drift first), the shared closing band. The IT-department
|
||||
page shows the drift screenshot. The service-provider page says where it
|
||||
stops today.
|
||||
|
||||
## NIS2
|
||||
|
||||
Article by article (eight rows plus one "out of scope"), a dot and a word for
|
||||
coverage, what netOrk records along the way, the audit log screenshot. States
|
||||
plainly that netOrk does not make anyone compliant.
|
||||
|
||||
## Plugins, Glossary, Getting started, Impressum, Datenschutz
|
||||
|
||||
Plugins: the five included ones with the hosts they talk to, how to write one,
|
||||
one code example. Glossary: every term from `src/glossary/terms.ts` with a
|
||||
category index. Getting started: "no public installer yet", write to us.
|
||||
Impressum and Datenschutz: **placeholders** — the final text must replace the
|
||||
yellow box before netork.io goes live.
|
||||
|
||||
## Navigation and footer
|
||||
|
||||
Nav: wordmark · Who it's for ▾ · NIS2 · Plugins · Pricing · DE/EN · "Buy a
|
||||
licence". Below `lg` a menu button. Footer: product links, persona links,
|
||||
contact, "no cookies, no tracking, no requests to anyone else", © line with
|
||||
Impressum and Datenschutz.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 99 KiB 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())
|
||||
@@ -16,6 +16,10 @@ Log in as `netork` / `netork-demo`.
|
||||
- Only the API and the UI run. There is no worker, no beat and no Redis, so
|
||||
nothing polls or reaches a device. Stored credentials are emptied, and the
|
||||
encryption key is random per start.
|
||||
- Secrets are emptied — except the ones netOrk compares with each other (Wi-Fi
|
||||
keys on an SSID against the key read from the access point). Those become a
|
||||
keyed hash, so equal stays equal and the drift view shows the real state
|
||||
instead of invented deviations. The key exists only for one run.
|
||||
- The mapping from real to demo names lives outside the repo in
|
||||
`~/.config/netork-screenshots/demo-map.json`, because it lists the real names.
|
||||
Domains become `example.demo`.
|
||||
|
||||
@@ -33,6 +33,7 @@ import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -183,9 +184,19 @@ class Mapper:
|
||||
return ".".join(mapped[::-1]) + ".in-addr.arpa"
|
||||
|
||||
# -- whole strings -------------------------------------------------------
|
||||
@staticmethod
|
||||
def _secret(m: re.Match) -> str:
|
||||
name, sep = m.group(1), m.group(2)
|
||||
if name in SECRET_JSON_KEEP:
|
||||
return m.group(0)
|
||||
raw = m.group(0)[m.group(0).index(sep) + len(sep) + 1:-1]
|
||||
if not raw or not COMPARED_SECRET.match(name):
|
||||
return f'"{name}"{sep}""'
|
||||
value = json.loads(f'"{raw}"') # the value as the column would hold it
|
||||
return f'"{name}"{sep}"{secret_token(value)}"'
|
||||
|
||||
def text(self, s: str) -> str:
|
||||
s = SECRET_JSON.sub(lambda m: m.group(0) if m.group(1) in SECRET_JSON_KEEP
|
||||
else f'"{m.group(1)}"{m.group(2)}""', s)
|
||||
s = SECRET_JSON.sub(self._secret, s)
|
||||
s = REVERSE.sub(self.reverse, s)
|
||||
s = EMAIL.sub(self.email, s)
|
||||
if self.domain_re:
|
||||
@@ -219,6 +230,18 @@ SECRET_JSON = re.compile(
|
||||
r'"((?:[A-Za-z0-9_]*_)?(?:key|psk|passphrase|password|passwd|secret|token|private_key|ft_key|sae_password))"'
|
||||
r'(\s*:\s*)"(?:[^"\\]|\\.)*"')
|
||||
SECRET_JSON_KEEP = {"public_key", "entry_key", "key_type", "is_secret", "ssh_key_id"}
|
||||
|
||||
# Secrets netOrk compares with each other (the Wi-Fi key stored on an SSID against
|
||||
# the key read from the access point). Emptying them would invent drift that never
|
||||
# existed, so they become a keyed hash instead: equal stays equal, nothing can be
|
||||
# reversed, and the key lives only for this run.
|
||||
COMPARED_SECRET = re.compile(r"^(passphrase|psk|ft_key|wpa_key|key|sae_password)$", re.I)
|
||||
RUN_KEY = secrets.token_hex(32)
|
||||
|
||||
|
||||
def secret_token(value: str) -> str:
|
||||
"""Same formula as the SQL in scrub_secrets: md5(run key || value)."""
|
||||
return "demo-" + hashlib.md5((RUN_KEY + value).encode()).hexdigest()[:16]
|
||||
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
|
||||
|
||||
# Whole tables that only hold secrets or personal delivery data.
|
||||
@@ -284,14 +307,22 @@ async def scrub_secrets(con, dry: bool) -> None:
|
||||
continue
|
||||
if dt not in ("text", "character varying", "jsonb", "json", "bytea"):
|
||||
continue # flags like require_password are booleans
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
|
||||
if not n:
|
||||
continue
|
||||
if COMPARED_SECRET.match(c) and dt in ("text", "character varying"):
|
||||
print(f" {t}.{c}: {n} replaced by keyed hash")
|
||||
if not dry:
|
||||
await con.execute(
|
||||
f'UPDATE "{t}" SET "{c}" = \'demo-\' || left(md5($1 || "{c}"), 16) '
|
||||
f'WHERE "{c}" IS NOT NULL AND "{c}" <> \'\'', RUN_KEY)
|
||||
continue
|
||||
value = "NULL" if nullable == "YES" else ("'{}'" if dt in ("jsonb", "json") else "''")
|
||||
if dt == "bytea" and nullable != "YES":
|
||||
value = "''::bytea"
|
||||
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
|
||||
if n:
|
||||
print(f" {t}.{c}: {n} emptied")
|
||||
if not dry:
|
||||
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
|
||||
print(f" {t}.{c}: {n} emptied")
|
||||
if not dry:
|
||||
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
|
||||
# Settings flagged secret keep their key, lose their value.
|
||||
if await con.fetchval("SELECT to_regclass('public.settings') IS NOT NULL"):
|
||||
n = await con.fetchval("SELECT count(*) FROM settings WHERE is_secret")
|
||||
|
||||
@@ -5,7 +5,7 @@ Normally that instance is the local demo copy from scripts/demo (anonymized
|
||||
production data), which this script logs into on its own:
|
||||
|
||||
capture.py --list-devices # prints IDs to pick for --var
|
||||
capture.py --var ap=<id> --var server=<id> [--only name ...]
|
||||
capture.py --var ap=<id> [--only name ...]
|
||||
|
||||
Against a real instance, log in by hand and cover what must not be seen:
|
||||
|
||||
@@ -42,7 +42,8 @@ STATE = Path(os.environ.get(
|
||||
# One term per line: site names, customer names, domains ... never committed.
|
||||
MASK_FILE = Path(os.environ.get(
|
||||
"NETORK_MASK_FILE", Path.home() / ".config" / "netork-screenshots" / "mask.txt"))
|
||||
OUT = Path(__file__).resolve().parents[2] / "public" / "screenshots"
|
||||
SITE = Path(__file__).resolve().parents[2]
|
||||
OUT = SITE / "public" / "screenshots"
|
||||
|
||||
VIEWPORT = {"width": 1600, "height": 1000}
|
||||
|
||||
@@ -123,13 +124,14 @@ def settle(page: Page) -> None:
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def publish(png: bytes, path: Path, width: int) -> None:
|
||||
def publish(png: bytes, path: Path, width: int) -> dict[str, int]:
|
||||
"""Scale the 2x capture down to its published width and store it as WebP."""
|
||||
img = Image.open(io.BytesIO(png)).convert("RGB")
|
||||
if img.width > width:
|
||||
img = img.resize((width, round(img.height * width / img.width)), Image.LANCZOS)
|
||||
img.save(path, "WEBP", quality=85, method=6)
|
||||
print(f" -> {path.name} {img.width}x{img.height}, {path.stat().st_size // 1024} KB")
|
||||
return {"width": img.width, "height": img.height}
|
||||
|
||||
|
||||
def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
@@ -137,6 +139,8 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
terms = mask_terms()
|
||||
tok = token() if LOCAL else None
|
||||
blocked: list[str] = []
|
||||
sizes_file = SITE / "src" / "data" / "screenshots.json"
|
||||
sizes: dict[str, dict[str, int]] = json.loads(sizes_file.read_text()) if sizes_file.exists() else {}
|
||||
|
||||
def guard(route):
|
||||
if route.request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
@@ -163,6 +167,8 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
print(f"skip {shot.name}: needs --var {e.args[0]}=<id>")
|
||||
continue
|
||||
print(f"{shot.name}: {path}")
|
||||
vw, vh = shot.viewport or (VIEWPORT["width"], VIEWPORT["height"])
|
||||
page.set_viewport_size({"width": vw, "height": vh})
|
||||
page.goto(f"{BASE}{path}")
|
||||
if page.url.rstrip("/").endswith("/login"):
|
||||
sys.exit("Session expired; run --login again.")
|
||||
@@ -182,12 +188,33 @@ def capture(variables: dict[str, str], only: set[str], mask: bool) -> None:
|
||||
masks += [page.get_by_text(PUBLIC_IPV4), page.get_by_text(EMAIL)]
|
||||
masks += [page.get_by_text(t) for t in terms]
|
||||
clip = None
|
||||
if shot.height:
|
||||
clip = {"x": 0, "y": 0, "width": VIEWPORT["width"], "height": shot.height}
|
||||
if shot.clip:
|
||||
x, y, w, h = shot.clip
|
||||
clip = {"x": x, "y": y, "width": w, "height": h}
|
||||
elif shot.element:
|
||||
boxes = []
|
||||
for sel in ([shot.element] if isinstance(shot.element, str) else shot.element):
|
||||
el = page.locator(sel).first
|
||||
el.scroll_into_view_if_needed()
|
||||
b = el.bounding_box()
|
||||
if not b:
|
||||
sys.exit(f"{shot.name}: element not found: {sel}")
|
||||
boxes.append(b)
|
||||
left = min(b["x"] for b in boxes)
|
||||
top = min(b["y"] for b in boxes)
|
||||
right = max(b["x"] + b["width"] for b in boxes)
|
||||
bottom = max(b["y"] + b["height"] for b in boxes)
|
||||
box = {"x": left, "y": top, "width": right - left, "height": bottom - top}
|
||||
x0, y0 = max(0, box["x"] - shot.pad), max(0, box["y"] - shot.pad)
|
||||
clip = {"x": x0, "y": y0,
|
||||
"width": min(vw - x0, box["width"] + 2 * shot.pad),
|
||||
"height": min(vh - y0, box["height"] + 2 * shot.pad)}
|
||||
png = page.screenshot(full_page=shot.full_page, clip=clip, mask=masks,
|
||||
mask_color="#334155", animations="disabled")
|
||||
publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||
sizes[shot.name] = publish(png, OUT / f"{shot.name}.webp", shot.width)
|
||||
browser.close()
|
||||
# The site reads these to reserve the right space for each image.
|
||||
sizes_file.write_text(json.dumps(dict(sorted(sizes.items())), indent=2) + "\n")
|
||||
if blocked:
|
||||
print("Blocked non-GET requests (nothing was sent):")
|
||||
for b in sorted(set(blocked)):
|
||||
|
||||
@@ -21,9 +21,15 @@ class Shot:
|
||||
wait_for: str = "main"
|
||||
mask: list[str] = field(default_factory=list)
|
||||
full_page: bool = False
|
||||
# Crop height in CSS pixels; None keeps the viewport height.
|
||||
height: int | None = None
|
||||
# Width of the published WebP in pixels (the capture is 3200 wide).
|
||||
# Crop to a region (x, y, width, height in CSS px of the viewport) ...
|
||||
clip: tuple[int, int, int, int] | None = None
|
||||
# ... or to one element, plus `pad` px around it. Cropping shows less of a
|
||||
# real screen; it never changes what is on it.
|
||||
element: str | list[str] | None = None # several: crop to what they cover together
|
||||
pad: int = 16
|
||||
# Viewport for this shot, (width, height) in CSS px; default in capture.py.
|
||||
viewport: tuple[int, int] | None = None
|
||||
# Width of the published WebP in pixels (captures are taken at 2x).
|
||||
width: int = 1600
|
||||
# Selectors clicked in order before the shot, first match each. Only for
|
||||
# controls that change the view (filters, tabs); the API guard in
|
||||
@@ -31,18 +37,26 @@ class Shot:
|
||||
clicks: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# The drift comparison on a device page: the summary line ("… (42 compared)") down to
|
||||
# the end of the table. The card around it stretches to the window height.
|
||||
DRIFT_CARD = ["xpath=//*[contains(text(), 'compared)')]", "xpath=//table[.//th[contains(., 'Expected')]]"]
|
||||
|
||||
SHOTS: list[Shot] = [
|
||||
Shot("devices", "/devices", width=2400),
|
||||
Shot("device-detail", "/devices/{ap}#networking/interfaces"),
|
||||
Shot("vlans", "/vlans"),
|
||||
Shot("device-security", "/devices/{server}#security/assessment"),
|
||||
Shot("vulnerabilities", "/vulnerabilities"),
|
||||
Shot("dashboard", "/"),
|
||||
# Home hero: an access point checked against its profile, wide and short.
|
||||
Shot("drift", "/devices/{ap}#drift", wait_for="main table", element=DRIFT_CARD,
|
||||
pad=28, viewport=(1440, 900), width=2400),
|
||||
# Phones: the same finding from "Parameter" to the status badge, readable at 390px.
|
||||
Shot("drift-narrow", "/devices/{ap}#drift", wait_for="main table",
|
||||
element=["xpath=//th[contains(., 'Parameter')]", "xpath=//th[contains(., 'Actual')]",
|
||||
"xpath=//tbody//td[contains(., 'Remote Syslog')]",
|
||||
"xpath=//tbody//span[contains(., 'Incomplete')]",
|
||||
"xpath=//tbody//*[starts-with(normalize-space(text()), 'Set this field')]"],
|
||||
pad=16, viewport=(1180, 900), width=1200),
|
||||
Shot("vulnerabilities", "/vulnerabilities", clip=(256, 40, 1344, 620), width=2400),
|
||||
# Background polls drown out what people did: filter the scheduler out,
|
||||
# the way a reader would (click a source badge, then flip it to exclude).
|
||||
Shot("audit-log", "/audit-log", clicks=[
|
||||
Shot("audit-log", "/audit-log", clip=(256, 40, 1344, 560), width=2400, clicks=[
|
||||
"tbody td >> text=scheduler",
|
||||
"button[title='Click to toggle include/exclude']",
|
||||
]),
|
||||
Shot("service-checks", "/monitoring/checks"),
|
||||
]
|
||||
|
||||
@@ -1,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 />
|
||||
|
||||
@@ -1,56 +1,80 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { CONTACT_EMAIL } from '../i18n/translations'
|
||||
import { Container } from './ui'
|
||||
import { Wordmark } from './Nav'
|
||||
|
||||
const linkCls = 'text-ink-muted transition-colors hover:text-ink'
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useLang()
|
||||
const f = t.footer
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: f.product,
|
||||
links: [
|
||||
{ to: '/pricing', label: t.nav.pricing },
|
||||
{ to: '/nis2', label: t.nav.nis2 },
|
||||
{ to: '/plugins', label: t.nav.plugins },
|
||||
{ to: '/glossary', label: f.glossary },
|
||||
{ to: '/docs/getting-started', label: f.gettingStarted },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: f.forWhom,
|
||||
links: [
|
||||
{ to: '/for/it-department', label: t.nav.forItems.itDepartment },
|
||||
{ to: '/for/it-support', label: t.nav.forItems.itSupport },
|
||||
{ to: '/for/msp', label: t.nav.forItems.msp },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<footer className="bg-slate-900 border-t border-slate-800 mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-16">
|
||||
<div className="shrink-0">
|
||||
<Link to="/" className="flex items-center gap-2.5 mb-3">
|
||||
<img src="/logo.png" alt="" className="h-6 w-6" aria-hidden="true" />
|
||||
<span className="font-semibold text-slate-100 tracking-tight">
|
||||
net<span className="text-sky-400">Ork</span>
|
||||
</span>
|
||||
<footer className="mt-auto border-t border-line">
|
||||
<Container className="py-14">
|
||||
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1.5fr]">
|
||||
<div>
|
||||
<Link to="/">
|
||||
<Wordmark />
|
||||
</Link>
|
||||
<p className="text-sm text-slate-500">{t.footer.tagline}</p>
|
||||
<p className="mt-3 text-sm text-ink-muted">{f.tagline}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-10 flex-1 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-400 mb-3">{t.footer.links}</p>
|
||||
<ul className="space-y-2">
|
||||
<li><Link to="/features" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.features}</Link></li>
|
||||
<li><Link to="/drivers" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.drivers}</Link></li>
|
||||
<li><Link to="/plugins" className="text-slate-500 hover:text-slate-100 transition-colors">{t.nav.plugins}</Link></li>
|
||||
<li><Link to="/roadmap" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.roadmap}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-400 mb-3">{t.footer.resources}</p>
|
||||
<ul className="space-y-2">
|
||||
<li><Link to="/docs/getting-started" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.gettingStarted}</Link></li>
|
||||
<li><Link to="/docs/architecture" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.architecture}</Link></li>
|
||||
<li><span className="text-slate-600">{t.footer.changelog}</span></li>
|
||||
<li><Link to="/nis2" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.nis2}</Link></li>
|
||||
<li><Link to="/glossary" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.glossary}</Link></li>
|
||||
<li><Link to="/for/it-department" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forItDepartment}</Link></li>
|
||||
<li><Link to="/for/it-support" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forItSupport}</Link></li>
|
||||
<li><Link to="/for/msp" className="text-slate-500 hover:text-slate-100 transition-colors">{t.footer.forMsp}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-400 mb-3">{t.footer.legal}</p>
|
||||
<ul className="space-y-2">
|
||||
<li><span className="text-slate-600">{t.footer.mit}</span></li>
|
||||
<li><span className="text-slate-600">{t.footer.privacy}</span></li>
|
||||
{columns.map((col) => (
|
||||
<div key={col.title} className="text-sm">
|
||||
<p className="font-medium text-ink">{col.title}</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{col.links.map((l) => (
|
||||
<li key={l.to}>
|
||||
<Link to={l.to} className={linkCls}>
|
||||
{l.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-ink">{f.contact}</p>
|
||||
<p className="mt-3">
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className={linkCls}>
|
||||
{CONTACT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
<p className="mt-3 text-ink-muted">{f.noTracking}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 flex flex-wrap gap-x-6 gap-y-2 border-t border-line pt-6 text-sm text-ink-faint">
|
||||
<span>© 2026 netOrk</span>
|
||||
<Link to="/impressum" className="transition-colors hover:text-ink">
|
||||
{f.imprint}
|
||||
</Link>
|
||||
<Link to="/datenschutz" className="transition-colors hover:text-ink">
|
||||
{f.privacy}
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,17 +45,17 @@ export default function GlossaryMark({ id, children }: { id: string; children: R
|
||||
ref={anchorRef}
|
||||
to={`/glossary#${entry.id}`}
|
||||
onMouseEnter={handleEnter}
|
||||
className="group relative border-b border-dotted border-sky-500/60 hover:border-sky-400 hover:text-sky-300 transition-colors"
|
||||
className="group relative underline decoration-ink-faint decoration-dotted underline-offset-4 transition-colors hover:text-accent hover:decoration-accent"
|
||||
>
|
||||
{children}
|
||||
{/* display:none while hidden: an invisible box would still widen the page on phones */}
|
||||
<span
|
||||
className={`pointer-events-none absolute bottom-full z-20 mb-2 w-64 rounded-lg
|
||||
border border-slate-700 bg-slate-900 p-3 text-left text-xs font-normal normal-case
|
||||
text-slate-400 shadow-2xl hidden group-hover:block group-focus-visible:block
|
||||
className={`pointer-events-none absolute bottom-full z-20 mb-2 hidden w-64 rounded-lg
|
||||
bg-ink p-3 text-left text-xs font-normal normal-case leading-relaxed tracking-normal
|
||||
text-paper/75 no-underline shadow-lg group-hover:block group-focus-visible:block
|
||||
${PLACEMENT_CLASS[placement]}`}
|
||||
>
|
||||
<span className="mb-1 block font-semibold text-slate-200">{name}</span>
|
||||
<span className="mb-1 block font-semibold text-paper">{name}</span>
|
||||
{entry.definition[lang]}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -3,32 +3,27 @@ import { Link, useLocation } from 'react-router-dom'
|
||||
import { Bars3Icon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import type { Lang } from '../i18n/translations'
|
||||
import { ButtonLink, Container } from './ui'
|
||||
|
||||
const dropdownItemCls =
|
||||
'block px-4 py-2 text-sm text-slate-400 hover:text-slate-100 hover:bg-slate-800 transition-colors'
|
||||
export function Wordmark() {
|
||||
return (
|
||||
<span className="text-[1.0625rem] font-semibold tracking-tight text-ink">
|
||||
net<span className="text-accent">Ork</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const mobileItemCls =
|
||||
'block rounded-lg px-3 py-2 text-sm text-slate-300 hover:text-slate-100 hover:bg-slate-800 transition-colors'
|
||||
const mobileHeadingCls = 'px-3 pt-4 pb-1 text-xs font-semibold uppercase tracking-widest text-slate-500'
|
||||
const linkCls = (active: boolean) =>
|
||||
`text-sm transition-colors ${active ? 'text-ink' : 'text-ink-muted hover:text-ink'}`
|
||||
|
||||
function NavDropdown({
|
||||
label,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
function NavDropdown({ label, active, children }: { label: string; active: boolean; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
@@ -42,15 +37,14 @@ function NavDropdown({
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 text-sm transition-colors ${
|
||||
active ? 'text-slate-100' : 'text-slate-400 hover:text-slate-100'
|
||||
}`}
|
||||
aria-expanded={open}
|
||||
className={`flex items-center gap-1 ${linkCls(active)}`}
|
||||
>
|
||||
{label}
|
||||
<ChevronDownIcon className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full mt-2 left-0 w-52 rounded-lg border border-slate-800 bg-slate-900 py-1 shadow-xl">
|
||||
<div className="absolute left-0 top-full mt-3 w-56 rounded-lg border border-line bg-paper py-1.5 shadow-lg">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -58,128 +52,86 @@ function NavDropdown({
|
||||
)
|
||||
}
|
||||
|
||||
const dropdownItemCls = 'block px-4 py-2 text-sm text-ink-soft transition-colors hover:bg-paper-2 hover:text-ink'
|
||||
const mobileItemCls = 'block rounded-md px-3 py-2 text-ink-soft transition-colors hover:bg-paper-2 hover:text-ink'
|
||||
const mobileHeadingCls = 'px-3 pb-1 pt-4 text-eyebrow font-semibold uppercase text-ink-faint'
|
||||
|
||||
export default function Nav() {
|
||||
const location = useLocation()
|
||||
const { pathname } = useLocation()
|
||||
const { lang, setLang, t } = useLang()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false)
|
||||
}, [location.pathname])
|
||||
}, [pathname])
|
||||
|
||||
const linkCls = (path: string) =>
|
||||
`text-sm transition-colors ${
|
||||
location.pathname === path
|
||||
? 'text-slate-100'
|
||||
: 'text-slate-400 hover:text-slate-100'
|
||||
}`
|
||||
|
||||
const isDocsActive =
|
||||
location.pathname.startsWith('/docs') || location.pathname === '/nis2' || location.pathname === '/glossary'
|
||||
const isForActive = location.pathname.startsWith('/for/') || location.pathname === '/nis2'
|
||||
const personas = [
|
||||
{ to: '/for/it-department', label: t.nav.forItems.itDepartment },
|
||||
{ to: '/for/it-support', label: t.nav.forItems.itSupport },
|
||||
{ to: '/for/msp', label: t.nav.forItems.msp },
|
||||
]
|
||||
const links = [
|
||||
{ to: '/nis2', label: t.nav.nis2 },
|
||||
{ to: '/plugins', label: t.nav.plugins },
|
||||
{ to: '/pricing', label: t.nav.pricing },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-10 bg-slate-900/80 backdrop-blur border-b border-slate-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center gap-8">
|
||||
<Link to="/" className="flex items-center gap-2.5 mr-2">
|
||||
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
|
||||
<span className="font-semibold text-slate-100 tracking-tight">
|
||||
net<span className="text-sky-400">Ork</span>
|
||||
</span>
|
||||
<nav className="sticky top-0 z-30 border-b border-line bg-paper/85 backdrop-blur">
|
||||
<Container className="flex h-14 items-center gap-8">
|
||||
<Link to="/" aria-label="netOrk" className="mr-2">
|
||||
<Wordmark />
|
||||
</Link>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-8">
|
||||
<Link to="/features" className={linkCls('/features')}>
|
||||
{t.nav.features}
|
||||
</Link>
|
||||
|
||||
<Link to="/drivers" className={linkCls('/drivers')}>
|
||||
{t.nav.drivers}
|
||||
</Link>
|
||||
|
||||
<NavDropdown label={t.nav.docs} active={isDocsActive}>
|
||||
<Link to="/docs/getting-started" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.gettingStarted}
|
||||
</Link>
|
||||
<Link to="/docs/architecture" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.architecture}
|
||||
</Link>
|
||||
<div className="my-1 border-t border-slate-800" />
|
||||
<Link to="/nis2" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.nis2}
|
||||
</Link>
|
||||
<Link to="/glossary" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.glossary}
|
||||
</Link>
|
||||
</NavDropdown>
|
||||
|
||||
<NavDropdown label={t.nav.forWhom} active={isForActive}>
|
||||
<Link to="/for/it-department" className={dropdownItemCls}>
|
||||
{t.nav.forItems.itDepartment}
|
||||
</Link>
|
||||
<Link to="/for/it-support" className={dropdownItemCls}>
|
||||
{t.nav.forItems.itSupport}
|
||||
</Link>
|
||||
<Link to="/for/msp" className={dropdownItemCls}>
|
||||
{t.nav.forItems.msp}
|
||||
</Link>
|
||||
<div className="my-1 border-t border-slate-800" />
|
||||
<Link to="/nis2" className={dropdownItemCls}>
|
||||
{t.nav.docsItems.nis2}
|
||||
</Link>
|
||||
</NavDropdown>
|
||||
|
||||
<Link to="/plugins" className={linkCls('/plugins')}>
|
||||
{t.nav.plugins}
|
||||
</Link>
|
||||
|
||||
<Link to="/roadmap" className={linkCls('/roadmap')}>
|
||||
{t.nav.roadmap}
|
||||
</Link>
|
||||
<div className="hidden items-center gap-7 lg:flex">
|
||||
<NavDropdown label={t.nav.forWhom} active={pathname.startsWith('/for/')}>
|
||||
{personas.map((p) => (
|
||||
<Link key={p.to} to={p.to} className={dropdownItemCls}>
|
||||
{p.label}
|
||||
</Link>
|
||||
))}
|
||||
</NavDropdown>
|
||||
{links.map((l) => (
|
||||
<Link key={l.to} to={l.to} className={linkCls(pathname === l.to)}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<LangSwitch lang={lang} setLang={setLang} />
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="hidden sm:inline-flex items-center gap-2 px-4 py-1.5 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
>
|
||||
{t.nav.getStarted}
|
||||
</Link>
|
||||
<ButtonLink to="/pricing" className="hidden h-9 px-4 sm:inline-flex">
|
||||
{t.nav.buy}
|
||||
</ButtonLink>
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label="Menu"
|
||||
className="lg:hidden -mr-1 p-1 text-slate-400 hover:text-slate-100 transition-colors"
|
||||
aria-label={t.nav.menu}
|
||||
className="-mr-1 p-1 text-ink-muted transition-colors hover:text-ink lg:hidden"
|
||||
>
|
||||
{menuOpen ? <XMarkIcon className="h-6 w-6" /> : <Bars3Icon className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="lg:hidden border-t border-slate-800 bg-slate-900 max-h-[calc(100vh-3.5rem)] overflow-y-auto">
|
||||
<div className="px-4 py-3">
|
||||
<Link to="/features" className={mobileItemCls}>{t.nav.features}</Link>
|
||||
<Link to="/drivers" className={mobileItemCls}>{t.nav.drivers}</Link>
|
||||
<Link to="/plugins" className={mobileItemCls}>{t.nav.plugins}</Link>
|
||||
<Link to="/roadmap" className={mobileItemCls}>{t.nav.roadmap}</Link>
|
||||
<p className={mobileHeadingCls}>{t.nav.docs}</p>
|
||||
<Link to="/docs/getting-started" className={mobileItemCls}>{t.nav.docsItems.gettingStarted}</Link>
|
||||
<Link to="/docs/architecture" className={mobileItemCls}>{t.nav.docsItems.architecture}</Link>
|
||||
<Link to="/nis2" className={mobileItemCls}>{t.nav.docsItems.nis2}</Link>
|
||||
<Link to="/glossary" className={mobileItemCls}>{t.nav.docsItems.glossary}</Link>
|
||||
<div className="max-h-[calc(100vh-3.5rem)] overflow-y-auto border-t border-line bg-paper lg:hidden">
|
||||
<Container className="py-3">
|
||||
{links.map((l) => (
|
||||
<Link key={l.to} to={l.to} className={mobileItemCls}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<p className={mobileHeadingCls}>{t.nav.forWhom}</p>
|
||||
<Link to="/for/it-department" className={mobileItemCls}>{t.nav.forItems.itDepartment}</Link>
|
||||
<Link to="/for/it-support" className={mobileItemCls}>{t.nav.forItems.itSupport}</Link>
|
||||
<Link to="/for/msp" className={mobileItemCls}>{t.nav.forItems.msp}</Link>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="sm:hidden mt-4 flex justify-center px-4 py-2 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
>
|
||||
{t.nav.getStarted}
|
||||
</Link>
|
||||
</div>
|
||||
{personas.map((p) => (
|
||||
<Link key={p.to} to={p.to} className={mobileItemCls}>
|
||||
{p.label}
|
||||
</Link>
|
||||
))}
|
||||
<ButtonLink to="/pricing" className="mt-4 w-full sm:hidden">
|
||||
{t.nav.buy}
|
||||
</ButtonLink>
|
||||
</Container>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
@@ -189,19 +141,18 @@ export default function Nav() {
|
||||
function LangSwitch({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
<button
|
||||
onClick={() => setLang('de')}
|
||||
className={`transition-colors ${lang === 'de' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
DE
|
||||
</button>
|
||||
<span className="text-slate-700">/</span>
|
||||
<button
|
||||
onClick={() => setLang('en')}
|
||||
className={`transition-colors ${lang === 'en' ? 'text-slate-100' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
{(['de', 'en'] as const).map((l, i) => (
|
||||
<span key={l} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-line-strong">/</span>}
|
||||
<button
|
||||
onClick={() => setLang(l)}
|
||||
aria-pressed={lang === l}
|
||||
className={`uppercase transition-colors ${lang === l ? 'text-ink' : 'text-ink-faint hover:text-ink-soft'}`}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,132 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Cap = '✓' | '—'
|
||||
|
||||
interface Driver {
|
||||
name: string
|
||||
deviceType: string
|
||||
facts: Cap
|
||||
interfaces: Cap
|
||||
lldp: Cap
|
||||
vlans: Cap
|
||||
ssids: Cap
|
||||
health: Cap
|
||||
docker: Cap
|
||||
reboot: Cap
|
||||
configPush: Cap
|
||||
status: 'stable' | 'beta'
|
||||
}
|
||||
|
||||
const DRIVERS: Driver[] = [
|
||||
{ name: 'fritzbox', deviceType: 'AVM Fritz!Box routers (read-only)', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'stable' },
|
||||
{ name: 'hpe_officeconnect', deviceType: 'HPE OfficeConnect 1820 / 1920S switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'linux', deviceType: 'Generic Linux servers', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '—', configPush: '—', status: 'stable' },
|
||||
{ name: 'netgear_plus', deviceType: 'Netgear Plus switches (web UI)', facts: '✓', interfaces: '✓', lldp: '—', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'netgear_smart', deviceType: 'Netgear Smart Managed Pro switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'beta' },
|
||||
{ name: 'openmediavault', deviceType: 'OpenMediaVault NAS', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '—', configPush: '—', status: 'stable' },
|
||||
{ name: 'openwrt', deviceType: 'OpenWrt routers and access points', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '✓', health: '✓', docker: '—', reboot: '✓', configPush: '✓', status: 'stable' },
|
||||
{ name: 'opnsense', deviceType: 'OPNsense firewalls', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||
{ name: 'procurve', deviceType: 'HPE ProCurve / Aruba switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||
{ name: 'proxmox', deviceType: 'Proxmox VE hypervisors', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '✓', status: 'stable' },
|
||||
{ name: 'qnap_qts', deviceType: 'QNAP NAS on QTS', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '—', docker: '✓', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'sonos', deviceType: 'Sonos speakers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'tplink_jetstream', deviceType: 'TP-Link JetStream switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '—', configPush: '✓', status: 'stable' },
|
||||
{ name: 'yealink', deviceType: 'Yealink IP phones', facts: '✓', interfaces: '—', lldp: '—', vlans: '—', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
{ name: 'zyxel', deviceType: 'Zyxel VMG routers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
|
||||
]
|
||||
|
||||
const NAPALM_BUILTIN = ['Arista EOS', 'Cisco IOS', 'Cisco IOS-XE', 'Cisco NX-OS', 'Juniper JunOS']
|
||||
|
||||
const CAPS: { key: keyof Driver; label: string }[] = [
|
||||
{ key: 'facts', label: 'Facts' },
|
||||
{ key: 'interfaces', label: 'Ifaces' },
|
||||
{ key: 'lldp', label: 'LLDP' },
|
||||
{ key: 'vlans', label: 'VLANs' },
|
||||
{ key: 'ssids', label: 'SSIDs' },
|
||||
{ key: 'health', label: 'Health' },
|
||||
{ key: 'docker', label: 'Docker' },
|
||||
{ key: 'reboot', label: 'Reboot' },
|
||||
{ key: 'configPush', label: 'Config push' },
|
||||
]
|
||||
|
||||
function StatusBadge({ status }: { status: 'stable' | 'beta' }) {
|
||||
return (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
status === 'stable'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Drivers() {
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.drivers.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">{linkify(t.drivers.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-16 overflow-x-auto">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4">{t.drivers.customHeading}</h2>
|
||||
<div className="rounded-xl border border-slate-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900">
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Driver</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Device type</th>
|
||||
{CAPS.map((c) => (
|
||||
<th key={c.key} className="px-3 py-3 text-center font-medium text-slate-500 whitespace-nowrap">
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DRIVERS.map((d) => (
|
||||
<tr key={d.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40 transition-colors">
|
||||
<td className="px-4 py-3 font-mono text-sky-400 whitespace-nowrap">{d.name}</td>
|
||||
<td className="px-4 py-3 text-slate-300 whitespace-nowrap">{d.deviceType}</td>
|
||||
{CAPS.map((c) => (
|
||||
<td key={c.key} className="px-3 py-3 text-center">
|
||||
<span className={d[c.key] === '✓' ? 'text-green-400' : 'text-slate-700'}>
|
||||
{d[c.key]}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-3"><StatusBadge status={d.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-1 text-xs text-slate-500">
|
||||
{t.drivers.notes.map((n) => <li key={n}>{linkify(n)}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4">{t.drivers.napalmHeading}</h2>
|
||||
<p className="text-sm text-slate-400 mb-6">{linkify(t.drivers.napalmSub)}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{NAPALM_BUILTIN.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-slate-700 bg-slate-900 text-xs font-medium text-slate-300"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
function Section({ title, items }: { title: string; items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4 pb-2 border-b border-slate-800">
|
||||
{title}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm text-slate-400 leading-relaxed">
|
||||
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||
{linkify(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Features() {
|
||||
const { t } = useLang()
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.features.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">{t.features.sub}</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
{t.features.sections.map((s) => (
|
||||
<Section key={s.title} title={s.title} items={s.items} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ServerStackIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string; drivers: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps', drivers: 'Runs on what you already have' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft', drivers: 'Läuft auf dem, was ihr schon habt' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'A different admin UI per vendor', body: 'OPNsense here, HP ProCurve there, a separate tab for OpenWRT and Proxmox. Every task starts with figuring out which UI to open.' },
|
||||
{ title: 'No single view of what\'s running where', body: 'Vendor, model, firmware, and status per device live in nobody\'s head — or a spreadsheet that\'s already out of date.' },
|
||||
{ title: 'Config changes with no audit trail', body: 'Someone tweaks a firewall rule directly on the device. Nobody documents it. It breaks something three weeks later.' },
|
||||
{ title: 'Manual SSH just to check a VLAN', body: 'Verifying interface status or VLAN membership means logging into the device directly, every time.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Für jeden Hersteller eine andere Oberfläche', body: 'OPNsense hier, HP ProCurve dort, ein eigener Tab für OpenWRT und Proxmox. Jede Aufgabe beginnt damit, herauszufinden, welche UI überhaupt zuständig ist.' },
|
||||
{ title: 'Keine einzige Ansicht, was wo läuft', body: 'Hersteller, Modell, Firmware und Status pro Gerät stehen in niemandes Kopf — oder in einer Tabelle, die längst veraltet ist.' },
|
||||
{ title: 'Konfigurationsänderungen ohne Audit-Trail', body: 'Jemand ändert eine Firewall-Regel direkt am Gerät. Niemand dokumentiert es. Drei Wochen später bricht etwas.' },
|
||||
{ title: 'Manuelles SSH nur um ein VLAN zu prüfen', body: 'Um Interface-Status oder VLAN-Zugehörigkeit zu verifizieren, muss man sich jedes Mal direkt am Gerät einloggen.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Device Management', body: 'CRUD for every device with credential profiles and SSH key management. Vendor, model, and OS auto-populate from NAPALM get_facts() — no manual data entry.' },
|
||||
{ title: 'Config drift detection & one-click fix', body: 'Every poll compares device state against the desired state in netOrk. Drifted devices get a warning; fixing it streams live SSH output straight to the browser.' },
|
||||
{ title: 'Git-backed config history', body: 'Every poll snapshots the config into a local Git repository — full history, side-by-side diffs between any two points in time, and one-click restore for OPNsense.' },
|
||||
{ title: 'Ansible automation', body: '16 built-in roles (base, docker, adguard, wireguard, fail2ban, a mail server, and more) with automatic dependency resolution. Write your own roles too, with full run history per device.' },
|
||||
{ title: 'VM Provisioning', body: 'Cloud-Init VMs on Proxmox straight from the hypervisor\'s VMs tab — pick an image, a VLAN, and an IP, and netOrk handles the DHCP reservation and Device linking.' },
|
||||
{ title: 'Dashboards', body: 'Configurable, shareable dashboards built from 18 widgets on a WYSIWYG grid — replace the fixed layout with the view your team actually needs.' },
|
||||
{ title: 'Security assessment per device', body: 'TLS and SSH graded A to F, installed software and container images matched against known vulnerabilities, hardening benchmarks, and which networks can reach the device — without an agent on it.' },
|
||||
{ title: 'Vulnerability triage with owners', body: 'One queue across all devices, ordered by what is exploited and overdue. Every decision needs a reason; accepting a risk needs a permission operators do not have, and fixes are verified by the next assessments.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Geräteverwaltung', body: 'CRUD für jedes Gerät mit Credential-Profilen und SSH-Schlüsselverwaltung. Hersteller, Modell und OS werden automatisch über NAPALM get_facts() befüllt — keine manuelle Eingabe.' },
|
||||
{ title: 'Konfigurationsdrift-Erkennung & Ein-Klick-Fix', body: 'Bei jedem Poll wird der Gerätezustand mit dem Sollzustand in netOrk verglichen. Abweichende Geräte erhalten eine Warnung; der Fix streamt Live-SSH-Output direkt in den Browser.' },
|
||||
{ title: 'Git-basierte Konfigurationshistorie', body: 'Bei jedem Poll wird die Konfiguration in ein lokales Git-Repository gesnapshottet — vollständige Historie, Side-by-Side-Diffs zwischen beliebigen Zeitpunkten und Ein-Klick-Restore für OPNsense.' },
|
||||
{ title: 'Ansible-Automatisierung', body: '16 eingebaute Rollen (base, docker, adguard, wireguard, fail2ban, ein Mailserver und mehr) mit automatischer Abhängigkeitsauflösung. Eigene Rollen schreiben inklusive vollständiger Lauf-Historie pro Gerät.' },
|
||||
{ title: 'VM-Provisioning', body: 'Cloud-Init-VMs auf Proxmox direkt aus dem VMs-Tab des Hypervisors — Image, VLAN und IP auswählen, netOrk übernimmt DHCP-Reservierung und Geräteverknüpfung.' },
|
||||
{ title: 'Dashboards', body: 'Konfigurierbare, teilbare Dashboards aus 18 Widgets auf einem WYSIWYG-Grid — statt festem Layout die Ansicht, die euer Team wirklich braucht.' },
|
||||
{ title: 'Sicherheitsbewertung pro Gerät', body: 'TLS und SSH mit Note A bis F, installierte Software und Container-Images gegen bekannte Schwachstellen abgeglichen, Härtungs-Benchmarks, und welche Netze das Gerät erreichen — ohne Agent darauf.' },
|
||||
{ title: 'Schwachstellen-Triage mit Verantwortlichen', body: 'Eine Queue über alle Geräte, geordnet nach Ausgenutztem und Überfälligem. Jede Entscheidung braucht eine Begründung; ein Risiko zu akzeptieren braucht eine Berechtigung, die Operatoren nicht haben, und Behebungen verifizieren die nächsten Bewertungen.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForItDepartment() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<ServerStackIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItDepartment.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forItDepartment.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-4 pb-2 border-b border-slate-800">
|
||||
{labels.drivers}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-4">
|
||||
{lang === 'en'
|
||||
? 'Custom NAPALM drivers for 15 device types, plus the built-in NAPALM drivers.'
|
||||
: 'Eigene NAPALM-Treiber für 15 Gerätetypen, plus die integrierten NAPALM-Treiber.'}
|
||||
</p>
|
||||
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{lang === 'en' ? 'Full driver reference →' : 'Vollständige Treiberreferenz →'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forItDepartment.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forItDepartment.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forItDepartment.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { LifebuoyIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Is this device actually up right now?', body: 'Checking status means opening the vendor UI for that one device — or pinging it yourself and hoping.' },
|
||||
{ title: 'The same manual reboot, over and over', body: 'A flaky AP needs a power cycle every few weeks, and someone has to remember to do it and be there for it.' },
|
||||
{ title: '"Who changed this?" — with no record', body: 'A setting is different than it was yesterday. Nobody can say who touched it, when, or why.' },
|
||||
{ title: 'Full admin access just to fix one ticket', body: 'Resolving a single warning shouldn\'t require the keys to the whole network configuration.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Läuft das Gerät gerade wirklich?', body: 'Status prüfen heißt: die Hersteller-UI für genau dieses Gerät öffnen — oder selbst pingen und hoffen.' },
|
||||
{ title: 'Derselbe manuelle Reboot, immer wieder', body: 'Ein zickiger AP braucht alle paar Wochen einen Power-Cycle — und jemand muss daran denken und dafür vor Ort sein.' },
|
||||
{ title: '„Wer hat das geändert?" — ohne Protokoll', body: 'Eine Einstellung ist anders als gestern. Niemand kann sagen, wer sie wann und warum geändert hat.' },
|
||||
{ title: 'Voller Admin-Zugriff nur für ein Ticket', body: 'Eine einzelne Warnung zu beheben sollte nicht den Schlüssel zur gesamten Netzwerkkonfiguration erfordern.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Status at a glance', body: 'Per-device warning system with severity levels (error / warning / info); a dashboard warnings widget surfaces every open issue across sites without hunting per device.' },
|
||||
{ title: 'One-click Ack', body: 'Clears a warning immediately and writes an audit log entry. Config-change warnings accept the current state as the new baseline in the same click.' },
|
||||
{ title: 'Wake-on-LAN', body: 'Saved WOL targets per device, sent via the firewall\'s driver. On-demand "Wake now" or a recurring schedule; save a seen host as a target directly from the DHCP/ARP tabs.' },
|
||||
{ title: 'Scheduled reboots & updates', body: 'Scheduled reboots for OpenWRT APs with per-site concurrency locking, and package updates scheduled or applied with one click.' },
|
||||
{ title: 'Full audit log, filterable', body: 'Every action — who, when, what — filterable by date range, user, action, or resource, exportable to CSV or PDF.' },
|
||||
{ title: 'Roles scoped to the job', body: 'Viewer/operator roles, or a custom permission set, hand out exactly the access support work needs — without granting engineer-level config rights.' },
|
||||
{ title: 'Problems reach you on Signal', body: 'Failed devices, dead tunnels, expiring certificates — one message per outage instead of one per device, quiet hours per person, and any kind of message muted with one click.' },
|
||||
{ title: 'Service checks every minute', body: 'DNS, time servers, VPN tunnels, core daemons and gateways are checked about once a minute, derived from what netOrk already knows — a dead resolver shows up in minutes, not at the next ticket.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Status auf einen Blick', body: 'Gerätespezifisches Warnsystem mit Schweregraden (Fehler / Warnung / Info); ein Dashboard-Warnungs-Widget zeigt jedes offene Problem standortübergreifend, ohne pro Gerät zu suchen.' },
|
||||
{ title: 'Ein-Klick-Ack', body: 'Löscht eine Warnung sofort und protokolliert die Aktion im Audit-Log. Config-Change-Warnungen akzeptieren im selben Klick den aktuellen Zustand als neue Baseline.' },
|
||||
{ title: 'Wake-on-LAN', body: 'Gespeicherte WOL-Ziele pro Gerät, gesendet über den Treiber der Firewall. Ein-Klick-„Jetzt wecken" oder wiederkehrender Zeitplan; ein gesehener Host lässt sich direkt aus den DHCP-/ARP-Tabs als Ziel speichern.' },
|
||||
{ title: 'Geplante Reboots & Updates', body: 'Geplante Neustarts für OpenWRT-APs mit standortbezogener Concurrency-Sperre, Paket-Updates geplant oder per Ein-Klick angewendet.' },
|
||||
{ title: 'Vollständiges, filterbares Audit-Log', body: 'Jede Aktion — wer, wann, was — filterbar nach Datumsbereich, Benutzer, Aktion oder Ressource, exportierbar als CSV oder PDF.' },
|
||||
{ title: 'Rollen passend zur Aufgabe', body: 'Betrachter-/Operator-Rollen oder ein eigener Berechtigungssatz geben genau den Zugriff, den Support-Arbeit braucht — ohne Engineer-Rechte für die Konfiguration.' },
|
||||
{ title: 'Probleme kommen per Signal', body: 'Ausgefallene Geräte, tote Tunnel, ablaufende Zertifikate — eine Nachricht pro Ausfall statt einer pro Gerät, Ruhezeiten pro Person, und jede Nachrichtenart mit einem Klick stumm.' },
|
||||
{ title: 'Service-Checks jede Minute', body: 'DNS, Zeitserver, VPN-Tunnel, Kerndienste und Gateways werden etwa einmal pro Minute geprüft, abgeleitet aus dem, was netOrk ohnehin weiß — ein toter Resolver fällt in Minuten auf, nicht erst beim nächsten Ticket.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForItSupport() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<LifebuoyIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forItSupport.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forItSupport.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forItSupport.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forItSupport.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forItSupport.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; body: string }
|
||||
|
||||
const SECTION_LABELS: Record<'en' | 'de', { pain: string; features: string }> = {
|
||||
en: { pain: 'Your day today', features: 'Where netOrk helps' },
|
||||
de: { pain: 'Euer Alltag heute', features: 'Wo netOrk hilft' },
|
||||
}
|
||||
|
||||
const PAIN_POINTS: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Client sites you can\'t reach directly', body: 'Firewalled or disconnected networks mean juggling a VPN per client just to check a device.' },
|
||||
{ title: 'No unified view across clients', body: 'Each client\'s network lives in its own silo — there\'s no single dashboard across every site you manage.' },
|
||||
{ title: 'Proving what you did, and when', body: 'A client asks what changed after an incident, and there\'s no clean record to hand over — just memory and Slack messages.' },
|
||||
{ title: 'Client data in someone else\'s cloud', body: 'SaaS network-management tools mean client infrastructure data sits on a vendor\'s servers you don\'t control.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Kundenstandorte, die ihr nicht direkt erreicht', body: 'Firewalled oder getrennte Netzwerke bedeuten VPN-Jonglieren pro Kunde, nur um ein Gerät zu prüfen.' },
|
||||
{ title: 'Keine einheitliche Sicht über alle Kunden', body: 'Jedes Kundennetzwerk lebt in seiner eigenen Silo — kein einziges Dashboard über alle betreuten Standorte.' },
|
||||
{ title: 'Belegen, was ihr wann gemacht habt', body: 'Ein Kunde fragt nach einem Vorfall, was sich geändert hat — und es gibt kein sauberes Protokoll, nur Erinnerung und Slack-Nachrichten.' },
|
||||
{ title: 'Kundendaten in fremder Cloud', body: 'SaaS-Netzwerk-Tools bedeuten, dass die Infrastrukturdaten eurer Kunden auf Servern eines Anbieters liegen, den ihr nicht kontrolliert.' },
|
||||
],
|
||||
}
|
||||
|
||||
const FEATURES: Record<'en' | 'de', Item[]> = {
|
||||
en: [
|
||||
{ title: 'Satellite Deployments', body: 'A lightweight Docker agent deployed at a client site netOrk can\'t reach directly — polls devices locally and syncs results back to Central over HTTPS. Deployed in one flow via VM Provisioning.' },
|
||||
{ title: 'Central auto-routes around unreachable sites', body: 'Once a satellite is online and heartbeating, Central automatically skips direct polling for that site — no manual toggling per client.' },
|
||||
{ title: 'Actions work the same everywhere', body: 'Scheduled/on-demand reboots, the SNMP auto-fix flow, and discovery jobs all route through the same command channel, direct or behind a satellite. SNMP health-metric polling and WebSSH still need direct reach today.' },
|
||||
{ title: 'A real audit trail to hand a client', body: 'Every orchestration action is logged — who, what, when — filterable and exportable to CSV or PDF. Evidence, not a verbal assurance.' },
|
||||
{ title: 'Roles scoped per technician', body: 'Custom roles control exactly what each technician can do, from read-only visibility to full config access, per engagement.' },
|
||||
{ title: 'Self-hosted, no per-seat SaaS', body: 'Docker Compose deployment, no telemetry, no cloud dependency — runs on your infrastructure or a client\'s, not a vendor\'s.' },
|
||||
{ title: 'A dropped client site is one warning', body: 'When the tunnel to a site goes down, netOrk pauses polling there instead of turning every device red, names the tunnel, and re-polls everything the moment it is back.' },
|
||||
{ title: 'Checks from inside the client network', body: 'Service checks run from the site satellite, so DNS, time servers and gateways are tested from where the client sits — including a DHCP check that only works on the local network.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Satellite-Deployments', body: 'Ein leichtgewichtiger Docker-Agent an einem Kundenstandort, den netOrk nicht direkt erreicht — pollt Geräte lokal und synct Ergebnisse per HTTPS zurück an Central. In einem Ablauf per VM-Provisioning deployt.' },
|
||||
{ title: 'Central umgeht nicht erreichbare Standorte automatisch', body: 'Sobald ein Satellite online ist und heartbeatet, überspringt Central automatisch das direkte Polling für diesen Standort — kein manuelles Umschalten pro Kunde.' },
|
||||
{ title: 'Aktionen funktionieren überall gleich', body: 'Geplante/On-Demand-Reboots, der SNMP-Auto-Fix und Discovery-Jobs laufen über denselben Command-Kanal, egal ob direkt oder hinter einem Satellite. SNMP-Health-Metrik-Polling und WebSSH brauchen heute noch direkten Zugriff.' },
|
||||
{ title: 'Ein echter Audit-Trail für den Kunden', body: 'Jede Orchestrierungsaktion wird protokolliert — wer, was, wann — filterbar und exportierbar als CSV oder PDF. Beleg statt mündlicher Zusicherung.' },
|
||||
{ title: 'Rollen pro Techniker', body: 'Benutzerdefinierte Rollen legen genau fest, was jeder Techniker darf — von reinem Lesezugriff bis vollem Konfigurationszugriff, je nach Einsatz.' },
|
||||
{ title: 'Self-hosted, kein Pro-Seat-SaaS', body: 'Docker-Compose-Deployment, keine Telemetrie, keine Cloud-Abhängigkeit — läuft auf eurer Infrastruktur oder der eines Kunden, nicht bei einem Anbieter.' },
|
||||
{ title: 'Ein ausgefallener Kundenstandort ist eine Warnung', body: 'Fällt der Tunnel zu einem Standort, pausiert netOrk dort das Polling, statt jedes Gerät rot zu färben, nennt den Tunnel und fragt alles sofort neu ab, sobald er zurück ist.' },
|
||||
{ title: 'Checks aus dem Kundennetz heraus', body: 'Service-Checks laufen vom Standort-Satellite, DNS, Zeitserver und Gateways werden also von dort geprüft, wo der Kunde sitzt — inklusive eines DHCP-Checks, der nur im lokalen Netz funktioniert.' },
|
||||
],
|
||||
}
|
||||
|
||||
export default function ForMsp() {
|
||||
const { lang, t } = useLang()
|
||||
const labels = SECTION_LABELS[lang]
|
||||
const painPoints = PAIN_POINTS[lang]
|
||||
const features = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<BuildingOffice2Icon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.forMsp.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.forMsp.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.pain}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{painPoints.map((p) => (
|
||||
<div key={p.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(p.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{labels.features}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{f.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(f.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.forMsp.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.forMsp.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.forMsp.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,24 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { EnvelopeIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { CONTACT_EMAIL } from '../i18n/translations'
|
||||
import { ButtonLink, PageHeader } from '../components/ui'
|
||||
|
||||
export default function GettingStarted() {
|
||||
const { t } = useLang()
|
||||
const g = t.gettingStarted
|
||||
usePageTitle(t.meta.gettingStarted)
|
||||
|
||||
return (
|
||||
<div className="py-24 md:py-40">
|
||||
<div className="max-w-2xl mx-auto px-6 text-center">
|
||||
<p className="text-sm font-medium text-sky-400 mb-4 font-mono">{t.gettingStarted.label}</p>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-slate-100 leading-tight mb-6 whitespace-pre-line">
|
||||
{t.gettingStarted.heading}
|
||||
</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed mb-10">
|
||||
{t.gettingStarted.sub}
|
||||
</p>
|
||||
<a
|
||||
href="mailto:christian@manivong.de"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
>
|
||||
<EnvelopeIcon className="h-4 w-4" />
|
||||
{t.gettingStarted.cta}
|
||||
</a>
|
||||
<div className="mt-12 border-t border-slate-800 pt-8">
|
||||
<p className="text-sm text-slate-500 mb-4">{t.gettingStarted.bottomText}</p>
|
||||
<div className="flex gap-6 justify-center">
|
||||
<Link to="/features" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{t.gettingStarted.bottomFeatures}
|
||||
</Link>
|
||||
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{t.gettingStarted.bottomDrivers}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-24">
|
||||
<PageHeader eyebrow={g.eyebrow} title={g.title} lead={g.lead}>
|
||||
<ButtonLink to={`mailto:${CONTACT_EMAIL}`}>{g.cta}</ButtonLink>
|
||||
<ButtonLink to="/#how" variant="secondary">
|
||||
{g.how}
|
||||
</ButtonLink>
|
||||
<ButtonLink to="/pricing" variant="secondary">
|
||||
{g.pricing}
|
||||
</ButtonLink>
|
||||
</PageHeader>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,56 +1,60 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { GLOSSARY, CATEGORY_ORDER, CATEGORY_LABELS } from '../glossary/terms'
|
||||
import { Container, PageHeader } from '../components/ui'
|
||||
|
||||
export default function Glossary() {
|
||||
const { lang, t } = useLang()
|
||||
const location = useLocation()
|
||||
usePageTitle(t.meta.glossary)
|
||||
|
||||
useEffect(() => {
|
||||
const id = location.hash.slice(1)
|
||||
if (!id) return
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, [location.hash])
|
||||
const categories = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
entries: GLOSSARY.filter((e) => e.category === category).sort((a, b) => a.display.localeCompare(b.display)),
|
||||
})).filter((c) => c.entries.length > 0)
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.glossary.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{t.glossary.sub}</p>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader title={t.glossary.title} lead={t.glossary.lead} />
|
||||
|
||||
<div className="space-y-14">
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const entries = GLOSSARY.filter((e) => e.category === category).sort((a, b) =>
|
||||
a.display.localeCompare(b.display),
|
||||
)
|
||||
if (entries.length === 0) return null
|
||||
<Container className="pb-24">
|
||||
<div className="grid gap-10 lg:grid-cols-12 lg:gap-x-8">
|
||||
{/* Category index, sticky beside the list from lg up. */}
|
||||
<nav className="lg:col-span-3">
|
||||
<ul className="flex flex-wrap gap-x-5 gap-y-2 text-sm lg:sticky lg:top-24 lg:flex-col">
|
||||
{categories.map(({ category }) => (
|
||||
<li key={category}>
|
||||
<a href={`#cat-${category}`} className="text-ink-muted transition-colors hover:text-ink">
|
||||
{CATEGORY_LABELS[category][lang]}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{CATEGORY_LABELS[category][lang]}
|
||||
</h2>
|
||||
<dl className="space-y-6">
|
||||
<div className="space-y-16 lg:col-span-9">
|
||||
{categories.map(({ category, entries }) => (
|
||||
<section key={category} id={`cat-${category}`} className="scroll-mt-24">
|
||||
<h2 className="text-h3 text-ink-muted">{CATEGORY_LABELS[category][lang]}</h2>
|
||||
<dl className="mt-4">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} id={entry.id} className="scroll-mt-24">
|
||||
<dt className="flex flex-wrap items-baseline gap-2 mb-1">
|
||||
<span className="font-mono text-sm font-semibold text-sky-400">{entry.display}</span>
|
||||
{entry.fullName && (
|
||||
<span className="text-sm text-slate-300">{entry.fullName[lang]}</span>
|
||||
)}
|
||||
<div
|
||||
key={entry.id}
|
||||
id={entry.id}
|
||||
className="grid scroll-mt-24 gap-x-8 gap-y-1 border-t border-line py-4 sm:grid-cols-[12rem_1fr]"
|
||||
>
|
||||
<dt>
|
||||
<span className="font-mono text-sm font-semibold text-ink">{entry.display}</span>
|
||||
{entry.fullName && <span className="block text-sm text-ink-muted">{entry.fullName[lang]}</span>}
|
||||
</dt>
|
||||
<dd className="text-sm text-slate-500 leading-relaxed">{entry.definition[lang]}</dd>
|
||||
<dd className="text-ink-soft">{entry.definition[lang]}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,271 +1,112 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ChartBarIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
PuzzlePieceIcon,
|
||||
ShieldCheckIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
const DRIVERS = [
|
||||
'Fritz!Box', 'HPE OfficeConnect', 'HPE ProCurve / Aruba', 'Linux', 'Netgear',
|
||||
'OpenMediaVault', 'OpenWrt', 'OPNsense', 'Proxmox VE', 'QNAP QTS', 'Sonos',
|
||||
'TP-Link JetStream', 'Yealink', 'Zyxel VMG',
|
||||
]
|
||||
|
||||
function Screenshot({ src, label, alt }: { src: string; label: string; alt: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
|
||||
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||
<span className="ml-4 text-xs text-slate-500 font-mono">{label}</span>
|
||||
</div>
|
||||
<img src={src} alt={alt} loading="lazy" className="block w-full" width={1600} height={1000} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import Shot from '../components/Shot'
|
||||
import {
|
||||
ButtonLink,
|
||||
Container,
|
||||
CtaBand,
|
||||
Eyebrow,
|
||||
H2,
|
||||
RuleList,
|
||||
Section,
|
||||
Split,
|
||||
Steps,
|
||||
TextLink,
|
||||
} from '../components/ui'
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useLang()
|
||||
const h = t.home
|
||||
usePageTitle(t.meta.home)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 1 — Hero */}
|
||||
<section className="py-24 md:py-32">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-slate-100 leading-tight mb-6">
|
||||
{h.hero.line1}<br />
|
||||
<span className="text-sky-400">{h.hero.line2}</span>
|
||||
</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mx-auto mb-10">
|
||||
{linkify(h.hero.sub)}
|
||||
{/* The message, and the drift itself as proof. */}
|
||||
<header className="pt-16 md:pt-24">
|
||||
<Container>
|
||||
<Eyebrow>{h.hero.eyebrow}</Eyebrow>
|
||||
<h1 className="max-w-4xl text-display">{h.hero.title}</h1>
|
||||
<p className="mt-6 max-w-2xl text-lead text-ink-soft">{h.hero.lead}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<ButtonLink to="/pricing">{h.hero.primary}</ButtonLink>
|
||||
<ButtonLink to="/#how" variant="secondary">
|
||||
{h.hero.secondary}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
<div className="mt-14 md:mt-20">
|
||||
<Shot name="drift" narrow="drift-narrow" alt={t.shots.drift} caption={h.hero.caption} eager />
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
|
||||
<Section id="how">
|
||||
<Split
|
||||
aside={
|
||||
<>
|
||||
<Eyebrow>{h.how.eyebrow}</Eyebrow>
|
||||
<H2>{h.how.title}</H2>
|
||||
<p className="mt-6 text-ink-soft">{h.how.intro}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Steps items={h.how.steps} />
|
||||
</Split>
|
||||
</Section>
|
||||
|
||||
<Section id="hardware" band>
|
||||
<Split
|
||||
aside={
|
||||
<>
|
||||
<Eyebrow>{h.hardware.eyebrow}</Eyebrow>
|
||||
<H2>{h.hardware.title}</H2>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<RuleList items={h.hardware.groups.map((g) => ({ term: g.title, body: g.body }))} />
|
||||
<p className="mt-6 border-t border-line pt-5 text-sm text-ink-muted">{h.hardware.note}</p>
|
||||
</Split>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<div className="max-w-2xl">
|
||||
<Eyebrow>{h.vulns.eyebrow}</Eyebrow>
|
||||
<H2>{h.vulns.title}</H2>
|
||||
<p className="mt-6 text-lead text-ink-soft">{h.vulns.body}</p>
|
||||
<p className="mt-6">
|
||||
<TextLink to="/pricing">{h.vulns.link}</TextLink>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 justify-center">
|
||||
<a
|
||||
href="mailto:christian@manivong.de"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
>
|
||||
{h.hero.cta1}
|
||||
</a>
|
||||
<Link
|
||||
to="/features"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-slate-700 hover:border-slate-500 text-slate-300 hover:text-slate-100 transition-colors"
|
||||
>
|
||||
{h.hero.cta2}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-12">
|
||||
<Shot name="vulnerabilities" alt={t.shots.vulnerabilities} caption={h.vulns.caption} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section id="included" className="pt-0 md:pt-0">
|
||||
<div className="border-t border-line-strong pt-16 md:pt-20">
|
||||
<H2>{h.included.title}</H2>
|
||||
<div className="mt-10">
|
||||
<RuleList
|
||||
columns={2}
|
||||
items={h.included.items.map((i) => ({ term: i.title, body: i.body }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 mt-16">
|
||||
<Screenshot src="/screenshots/devices.webp" label="netork / devices" alt={h.shotAlt.devices} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 2 — Problem Statement */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-3xl mx-auto px-6 text-center">
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.problem)}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 3 — Core Capabilities */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap1.title}</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap1.body)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<ChartBarIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap2.title}</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap2.body)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-6">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<WrenchScrewdriverIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-100">{h.cap3.title}</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{linkify(h.cap3.body)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 4 — Driver Grid */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.driversHeading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl mx-auto mb-10">{linkify(h.driversSub)}</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{DRIVERS.map((d) => (
|
||||
<span key={d} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-slate-700 bg-slate-900 text-xs font-medium text-slate-300">
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Link to="/drivers" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{h.driversLink}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 5 — Screenshot Walkthrough */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6 space-y-24">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="mt-12 grid gap-4 border-t border-line pt-6 sm:grid-cols-[8rem_1fr]">
|
||||
<p className="text-eyebrow font-semibold uppercase text-accent">{h.nis2.label}</p>
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot1.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot1.body)}</p>
|
||||
<p className="max-w-2xl text-ink-soft">{h.nis2.body}</p>
|
||||
<p className="mt-3">
|
||||
<TextLink to="/nis2">{h.nis2.link}</TextLink>
|
||||
</p>
|
||||
</div>
|
||||
<Screenshot src="/screenshots/device-detail.webp" label="netork / devices / ap-floor1" alt={h.shotAlt.deviceDetail} />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="md:order-2">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot2.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot2.body)}</p>
|
||||
</div>
|
||||
<Screenshot src="/screenshots/vlans.webp" label="netork / vlans" alt={h.shotAlt.vlans} />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot3.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot3.body)}</p>
|
||||
</div>
|
||||
<Screenshot src="/screenshots/device-security.webp" label="netork / devices / proxy-01 / security" alt={h.shotAlt.deviceSecurity} />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="md:order-2">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot4.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot4.body)}</p>
|
||||
</div>
|
||||
<Screenshot src="/screenshots/vulnerabilities.webp" label="netork / vulnerabilities" alt={h.shotAlt.vulnerabilities} />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot5.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot5.body)}</p>
|
||||
</div>
|
||||
<Screenshot src="/screenshots/dashboard.webp" label="netork / dashboard" alt={h.shotAlt.dashboard} />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="md:order-2">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.screenshot6.heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.screenshot6.body)}</p>
|
||||
</div>
|
||||
<Screenshot src="/screenshots/service-checks.webp" label="netork / monitoring / checks" alt={h.shotAlt.serviceChecks} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 5b — NIS2 */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-sky-500 mb-3">{h.nis2Label}</p>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.nis2Heading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed mb-8">{linkify(h.nis2Body)}</p>
|
||||
<div className="mb-8">
|
||||
<Link to="/nis2" className="inline-flex items-center gap-2 text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
<ShieldCheckIcon className="h-4 w-4" />
|
||||
{h.nis2Link}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{h.nis2Items.map((m) => (
|
||||
<div key={m.art} className="flex gap-4">
|
||||
<span className="mt-0.5 shrink-0 w-28 text-xs font-mono text-sky-500">{m.art}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{m.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{linkify(m.detail)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Screenshot src="/screenshots/audit-log.webp" label="netork / audit-log" alt={h.shotAlt.auditLog} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 6 — Plugin System */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 md:p-12">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.pluginsHeading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mb-6">{linkify(h.pluginsBody)}</p>
|
||||
<Link to="/plugins" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{h.pluginsLink}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 7 — Deployment */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-start">
|
||||
<div>
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.deployHeading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed">{linkify(h.deployBody)}</p>
|
||||
</div>
|
||||
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6 font-mono text-sm text-slate-300 overflow-x-auto">
|
||||
<code>{`# Clone + configure
|
||||
git clone https://gitea.example.com/netork/netork.git
|
||||
cp .env.example .env
|
||||
# edit .env (DB URL, Redis password, secret key)
|
||||
|
||||
# Deploy
|
||||
bash scripts/deploy.sh 192.168.1.10`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-slate-800" />
|
||||
|
||||
{/* 8 — CTA */}
|
||||
<section className="py-24">
|
||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">{h.ctaHeading}</h2>
|
||||
<p className="text-base text-slate-400 leading-relaxed mb-8">{h.ctaBody}</p>
|
||||
<a
|
||||
href="mailto:christian@manivong.de"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
|
||||
>
|
||||
{h.ctaButton}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<CtaBand
|
||||
title={h.cta.title}
|
||||
body={h.cta.body}
|
||||
action={<ButtonLink to="/pricing">{h.cta.button}</ButtonLink>}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,233 +1,57 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ShieldCheckIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Coverage = 'covered' | 'partial' | 'roadmap' | 'na'
|
||||
type Requirement = { article: string; label: string; coverage: Coverage; netork: string }
|
||||
type EvidenceBlock = { trigger: string; produces: string[] }
|
||||
type ComingItem = { title: string; detail: string }
|
||||
|
||||
const REQUIREMENTS: Record<'en' | 'de', Requirement[]> = {
|
||||
en: [
|
||||
{ article: 'Art. 21 (2a)', label: 'Risk analysis & information system security policies', coverage: 'partial', netork: 'Config drift detection, SNMP health metrics, and security agent coverage across all devices provide a continuous risk baseline. A formal risk register is out of scope for netOrk.' },
|
||||
{ article: 'Art. 21 (2b)', label: 'Incident handling', coverage: 'partial', netork: 'Wazuh alert history, CrowdSec decisions, and Graylog syslog per device surface incidents at the network layer. A structured incident record with NIS2 Art. 23 reporting timers is on the roadmap.' },
|
||||
{ article: 'Art. 21 (2c)', label: 'Business continuity, backup management, disaster recovery', coverage: 'partial', netork: 'Every poll captures a configuration snapshot into a local Git repository — full history, a side-by-side diff viewer between any two points in time, and one-click restore for OPNsense. Backup/recovery for full device state beyond configuration is out of scope.' },
|
||||
{ article: 'Art. 21 (2d)', label: 'Supply chain security', coverage: 'covered', netork: 'Vendor, model, firmware, and OS version are tracked per device after every poll. The EOL Tracking plugin checks each device\'s OS version against the endoflife.date API daily and flags unsupported or soon-to-be-unsupported software.' },
|
||||
{ article: 'Art. 21 (2e)', label: 'Vulnerability handling in acquisition, development & maintenance', coverage: 'covered', netork: 'Installed software and container images on every device are matched against known vulnerabilities, rated by what each flaw means on that device. A triage queue records a decision per vulnerability — not applicable, accepted until, deferred until, fixed — each with a reason, a date and who decided, written to the audit log. Deferrals come back by themselves, and a daily reassessment verifies fixes. Vulnerability data requires a netOrk licence.' },
|
||||
{ article: 'Art. 21 (2f)', label: 'Assessing effectiveness of cybersecurity measures', coverage: 'partial', netork: 'The audit log records all orchestration actions. A per-site compliance dashboard (on roadmap) will aggregate security agent coverage, drift status, and patch metrics into a single view.' },
|
||||
{ article: 'Art. 21 (2g)', label: 'Basic cyber hygiene & cybersecurity training', coverage: 'na', netork: 'Out of scope for a network orchestration platform. Training and hygiene policies are handled at the organizational level.' },
|
||||
{ article: 'Art. 21 (2h)', label: 'Access control, asset management, human resources security', coverage: 'covered', netork: 'Full device inventory maintained automatically via discovery and continuous polling. RBAC with four built-in roles (viewer / operator / engineer / administrator) and custom role combinations. Complete audit log of all orchestration actions, filterable by date range, user, action, or resource — export to CSV or PDF for audit submissions.' },
|
||||
{ article: 'Art. 21 (2i)', label: 'Multi-factor authentication', coverage: 'covered', netork: 'TOTP-based MFA for netOrk user accounts — authenticator app at login, backup codes for emergencies, session invalidation on TOTP changes, enforceable per role. Terminal sessions to devices log in with each user\'s own SSH key, never a shared account, and opened and refused sessions are recorded.' },
|
||||
{ article: 'Art. 21 (2j)', label: 'Physical and environmental security', coverage: 'na', netork: 'Out of scope. Physical security of the infrastructure hosting netOrk is an organizational and facility concern.' },
|
||||
],
|
||||
de: [
|
||||
{ article: 'Art. 21 (2a)', label: 'Risikoanalyse und Sicherheitsrichtlinien für Informationssysteme', coverage: 'partial', netork: 'Konfigurationsdrift-Erkennung, SNMP-Gesundheitsmetriken und Security-Agent-Abdeckung über alle Geräte liefern eine kontinuierliche Risiko-Baseline. Ein formales Risikoregister liegt außerhalb des Scopes von netOrk.' },
|
||||
{ article: 'Art. 21 (2b)', label: 'Bewältigung von Sicherheitsvorfällen', coverage: 'partial', netork: 'Wazuh-Alert-Historie, CrowdSec-Entscheidungen und Graylog-Syslog pro Gerät decken Vorfälle auf Netzwerkebene auf. Ein strukturierter Incident-Datensatz mit NIS2 Art. 23 Melde-Timern ist auf der Roadmap.' },
|
||||
{ article: 'Art. 21 (2c)', label: 'Geschäftskontinuität, Backup-Management, Disaster Recovery', coverage: 'partial', netork: 'Bei jedem Poll wird ein Konfigurationssnapshot in einem lokalen Git-Repository gespeichert — vollständige Historie, ein Side-by-Side-Diff-Viewer zwischen beliebigen Zeitpunkten und Ein-Klick-Restore für OPNsense. Backup/Recovery für den vollständigen Gerätezustand über die Konfiguration hinaus liegt außerhalb des Scopes.' },
|
||||
{ article: 'Art. 21 (2d)', label: 'Supply-Chain-Sicherheit', coverage: 'covered', netork: 'Hersteller, Modell, Firmware und OS-Version werden nach jedem Poll pro Gerät erfasst. Das EOL-Tracking-Plugin gleicht die OS-Version jedes Geräts täglich mit der endoflife.date-API ab und kennzeichnet nicht mehr oder bald nicht mehr unterstützte Software.' },
|
||||
{ article: 'Art. 21 (2e)', label: 'Schwachstellenbehandlung bei Erwerb, Entwicklung & Wartung', coverage: 'covered', netork: 'Installierte Software und Container-Images jedes Geräts werden gegen bekannte Schwachstellen abgeglichen und danach bewertet, was die Lücke auf genau diesem Gerät bedeutet. Eine Triage-Queue hält pro Schwachstelle eine Entscheidung fest — nicht zutreffend, akzeptiert bis, zurückgestellt bis, behoben — jeweils mit Begründung, Datum und Entscheider, im Audit-Log protokolliert. Zurückgestelltes kommt von selbst zurück, eine tägliche Neubewertung verifiziert Behebungen. Die Schwachstellendaten setzen eine netOrk-Lizenz voraus.' },
|
||||
{ article: 'Art. 21 (2f)', label: 'Beurteilung der Wirksamkeit von Cybersicherheitsmaßnahmen', coverage: 'partial', netork: 'Das Audit-Log erfasst alle Orchestrierungsaktionen. Ein Compliance-Dashboard pro Standort (auf der Roadmap) wird Security-Agent-Abdeckung, Drift-Status und Patch-Metriken in einer Ansicht zusammenfassen.' },
|
||||
{ article: 'Art. 21 (2g)', label: 'Grundlegende Cyberhygiene und Cybersicherheitsschulungen', coverage: 'na', netork: 'Außerhalb des Scopes einer Netzwerk-Orchestrierungsplattform. Schulungen und Hygiene-Richtlinien werden auf Organisationsebene gehandhabt.' },
|
||||
{ article: 'Art. 21 (2h)', label: 'Zugangskontrolle, Asset-Management, Personalsicherheit', coverage: 'covered', netork: 'Vollständiges Geräteinventar automatisch über Discovery und kontinuierliches Polling gepflegt. RBAC mit vier integrierten Rollen (Betrachter / Operator / Ingenieur / Administrator) und benutzerdefinierten Rollenkombinationen. Vollständiges Audit-Log aller Orchestrierungsaktionen, filterbar nach Datumsbereich, Benutzer, Aktion oder Ressource — Export als CSV oder PDF für Audit-Einreichungen.' },
|
||||
{ article: 'Art. 21 (2i)', label: 'Multi-Faktor-Authentifizierung', coverage: 'covered', netork: 'TOTP-basierte MFA für netOrk-Benutzerkonten — Authenticator-App beim Login, Backup-Codes für Notfälle, Session-Invalidierung bei TOTP-Änderungen, pro Rolle erzwingbar. Terminal-Sitzungen zu Geräten melden sich mit dem eigenen SSH-Schlüssel des Benutzers an, nie mit einem geteilten Konto; geöffnete und verweigerte Sitzungen werden protokolliert.' },
|
||||
{ article: 'Art. 21 (2j)', label: 'Physische und umgebungsbezogene Sicherheit', coverage: 'na', netork: 'Außerhalb des Scopes. Die physische Sicherheit der Infrastruktur, die netOrk hostet, ist eine organisatorische und gebäudetechnische Angelegenheit.' },
|
||||
],
|
||||
}
|
||||
|
||||
const EVIDENCE: Record<'en' | 'de', EvidenceBlock[]> = {
|
||||
en: [
|
||||
{
|
||||
trigger: 'Every device poll',
|
||||
produces: [
|
||||
'Installed package list with versions',
|
||||
'Available update count',
|
||||
'Interface status, ARP table, DHCP leases',
|
||||
'VLAN membership vs. desired state (drift)',
|
||||
'SNMP health metrics (CPU, memory, interface counters)',
|
||||
'Wazuh agent status and CVE counts by severity',
|
||||
'Graylog syslog forwarding status',
|
||||
'CrowdSec decisions and ban counts',
|
||||
'Git-backed configuration snapshot, diffed against the previous one to detect unauthorized changes',
|
||||
],
|
||||
},
|
||||
{
|
||||
trigger: 'Every user action',
|
||||
produces: [
|
||||
'Audit log entry: user, timestamp, resource, action',
|
||||
'Before/after values for configuration changes',
|
||||
'Acknowledged warnings logged with the accepting user',
|
||||
],
|
||||
},
|
||||
{
|
||||
trigger: 'On demand',
|
||||
produces: [
|
||||
'Topology graph — network segmentation view',
|
||||
'Subnet browser — IP space coverage',
|
||||
'VLAN matrix — which devices carry which VLANs',
|
||||
'Configuration diff between any two snapshots; one-click restore (OPNsense)',
|
||||
'Audit log export to PDF / CSV, filtered by date range, user, action, or resource',
|
||||
],
|
||||
},
|
||||
],
|
||||
de: [
|
||||
{
|
||||
trigger: 'Bei jedem Geräte-Poll',
|
||||
produces: [
|
||||
'Installierte Paketliste mit Versionen',
|
||||
'Anzahl verfügbarer Updates',
|
||||
'Schnittstellenstatus, ARP-Tabelle, DHCP-Leases',
|
||||
'VLAN-Zugehörigkeit vs. Sollzustand (Drift)',
|
||||
'SNMP-Gesundheitsmetriken (CPU, Speicher, Schnittstellenzähler)',
|
||||
'Wazuh-Agent-Status und CVE-Anzahl nach Schweregrad',
|
||||
'Graylog-Syslog-Weiterleitungsstatus',
|
||||
'CrowdSec-Entscheidungen und Ban-Anzahl',
|
||||
'Git-basierter Konfigurationssnapshot, gegen den vorherigen geprüft, um nicht autorisierte Änderungen zu erkennen',
|
||||
],
|
||||
},
|
||||
{
|
||||
trigger: 'Bei jeder Benutzeraktion',
|
||||
produces: [
|
||||
'Audit-Log-Eintrag: Benutzer, Zeitstempel, Ressource, Aktion',
|
||||
'Vorher/Nachher-Werte für Konfigurationsänderungen',
|
||||
'Bestätigte (acked) Warnungen werden mit dem bestätigenden Benutzer protokolliert',
|
||||
],
|
||||
},
|
||||
{
|
||||
trigger: 'Auf Abruf',
|
||||
produces: [
|
||||
'Topologie-Graph — Netzwerksegmentierungs-Ansicht',
|
||||
'Subnetz-Browser — IP-Raum-Abdeckung',
|
||||
'VLAN-Matrix — welche Geräte welche VLANs führen',
|
||||
'Konfigurations-Diff zwischen zwei beliebigen Snapshots; Ein-Klick-Restore (OPNsense)',
|
||||
'Audit-Log-Export als PDF / CSV, gefiltert nach Datumsbereich, Benutzer, Aktion oder Ressource',
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const COMING: Record<'en' | 'de', ComingItem[]> = {
|
||||
en: [
|
||||
{ title: 'Compliance dashboard', detail: 'Per-site Art. 21 checklist: asset coverage, patch status, agent deployment, drift, syslog, audit activity.' },
|
||||
{ title: 'Incident workflow', detail: 'Structured incident records with NIS2 Art. 23 reporting timers (24 h / 72 h) and external webhook delivery.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Compliance-Dashboard', detail: 'Art. 21-Checkliste pro Standort: Asset-Abdeckung, Patch-Status, Agent-Deployment, Drift, Syslog, Audit-Aktivität.' },
|
||||
{ title: 'Incident-Workflow', detail: 'Strukturierte Incident-Datensätze mit NIS2 Art. 23 Melde-Timern (24 h / 72 h) und externer Webhook-Zustellung.' },
|
||||
],
|
||||
}
|
||||
|
||||
function CoverageTag({ coverage, labels }: { coverage: Coverage; labels: Record<string, string> }) {
|
||||
const base = 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap border'
|
||||
if (coverage === 'covered') return <span className={`${base} bg-green-500/15 text-green-400 border-green-500/20`}>{labels.covered}</span>
|
||||
if (coverage === 'partial') return <span className={`${base} bg-yellow-500/15 text-yellow-400 border-yellow-500/20`}>{labels.partial}</span>
|
||||
if (coverage === 'roadmap') return <span className={`${base} bg-sky-500/15 text-sky-400 border-sky-500/20`}>{labels.roadmap}</span>
|
||||
return <span className={`${base} bg-slate-700/50 text-slate-500 border-slate-700`}>{labels.na}</span>
|
||||
}
|
||||
import Shot from '../components/Shot'
|
||||
import { ButtonLink, CtaBand, H2, PageHeader, Section, Split, Status } from '../components/ui'
|
||||
|
||||
export default function Nis2() {
|
||||
const { lang, t } = useLang()
|
||||
const reqs = REQUIREMENTS[lang]
|
||||
const evidence = EVIDENCE[lang]
|
||||
const coming = COMING[lang]
|
||||
const coverageLabels = t.nis2.coverageLabels
|
||||
const legendLabels = t.nis2.legendLabels
|
||||
const { t } = useLang()
|
||||
const n = t.nis2
|
||||
usePageTitle(t.meta.nis2)
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<>
|
||||
<PageHeader eyebrow={n.eyebrow} title={n.title} lead={linkify(n.lead)} />
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<ShieldCheckIcon className="h-5 w-5 text-sky-400" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.nis2.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.nis2.sub)}</p>
|
||||
</div>
|
||||
<Section className="pt-0 md:pt-0">
|
||||
<H2>{n.tableTitle}</H2>
|
||||
<dl className="mt-10">
|
||||
{n.requirements.map((r) => (
|
||||
<div
|
||||
key={r.article}
|
||||
className="grid gap-x-8 gap-y-2 border-t border-line py-5 md:grid-cols-[6rem_16rem_1fr_8rem]"
|
||||
>
|
||||
<dt className="font-mono text-sm text-accent">Art. 21 {r.article}</dt>
|
||||
<dt className="text-h3 text-ink">{r.label}</dt>
|
||||
<dd className="text-ink-soft">{linkify(r.body)}</dd>
|
||||
<dd className="md:text-right">
|
||||
<Status coverage={r.coverage} label={n.coverage[r.coverage]} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.nis2.mappingHeading}
|
||||
</h2>
|
||||
<div className="space-y-0 rounded-xl border border-slate-800 overflow-hidden">
|
||||
{reqs.map((r, i) => (
|
||||
<div key={r.article} className={`p-5 ${i < reqs.length - 1 ? 'border-b border-slate-800' : ''}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-sky-500 shrink-0">{r.article}</span>
|
||||
<span className="text-sm font-medium text-slate-200">{r.label}</span>
|
||||
</div>
|
||||
<CoverageTag coverage={r.coverage} labels={coverageLabels} />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 leading-relaxed md:pl-24">{linkify(r.netork)}</p>
|
||||
</div>
|
||||
<Section band>
|
||||
<Split aside={<H2>{n.evidenceTitle}</H2>}>
|
||||
<ul>
|
||||
{n.evidence.map((e) => (
|
||||
<li key={e} className="border-t border-line py-4 text-ink-soft first:border-t-0 first:pt-0">
|
||||
{linkify(e)}
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-4 text-xs text-slate-500">
|
||||
{(['covered', 'partial', 'roadmap', 'na'] as Coverage[]).map((c) => (
|
||||
<span key={c} className="flex items-center gap-1.5">
|
||||
<CoverageTag coverage={c} labels={coverageLabels} />
|
||||
{legendLabels[c]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</Split>
|
||||
<div className="mt-14">
|
||||
<Shot name="audit-log" alt={t.shots.auditLog} caption={n.auditCaption} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-2 pb-2 border-b border-slate-800">
|
||||
{t.nis2.evidenceHeading}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 mb-6">{linkify(t.nis2.evidenceSub)}</p>
|
||||
<div className="space-y-6">
|
||||
{evidence.map((e) => (
|
||||
<div key={e.trigger} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-xs font-semibold text-sky-500 uppercase tracking-widest mb-3">{e.trigger}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{e.produces.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-sm text-slate-400">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
|
||||
{linkify(item)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.nis2.comingHeading}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{coming.map((item) => (
|
||||
<div key={item.title} className="flex gap-4">
|
||||
<div className="mt-2 shrink-0 h-1.5 w-1.5 rounded-full bg-sky-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{item.title}</p>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{linkify(item.detail)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Link to="/roadmap" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
|
||||
{lang === 'de' ? 'Vollständige Roadmap →' : 'Full roadmap →'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.nis2.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.nis2.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.nis2.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CtaBand
|
||||
title={t.personas.cta.title}
|
||||
body={t.personas.cta.body}
|
||||
action={<ButtonLink to="/pricing">{t.personas.cta.button}</ButtonLink>}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,250 +1,52 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PuzzlePieceIcon } from '@heroicons/react/24/outline'
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { usePageTitle } from '../lib/usePageTitle'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Building = { title: string; body: string }
|
||||
type BuiltinPlugin = { label: string; body: string; hosts: string[]; hooks: string[] }
|
||||
type Step = { title: string; body: string }
|
||||
type HookKind = { name: string; behavior: string; use: string }
|
||||
|
||||
const BUILDING_BLOCKS: Record<'en' | 'de', Building[]> = {
|
||||
en: [
|
||||
{ title: 'Metadata (PluginMeta)', body: 'name, label, description, version, a declared permission surface (db_read, db_write, external_hosts, hooks — for review, not runtime enforcement), and an optional Celery Beat schedule.' },
|
||||
{ title: 'Router', body: 'A FastAPI APIRouter, mounted under /api/v1 at startup — but only for plugins that are enabled.' },
|
||||
{ title: 'Tasks', body: 'Celery tasks for anything that talks to an external system. Hooks queue work; they never block the event loop on I/O.' },
|
||||
{ title: 'Hooks', body: 'Handlers registered against the central event bus (hook_registry) via the @hook decorator — react to events like poll.complete.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Metadaten (PluginMeta)', body: 'name, label, description, version, eine deklarierte Permission-Surface (db_read, db_write, external_hosts, hooks — für Review, nicht zur Laufzeit erzwungen) und ein optionaler Celery-Beat-Schedule.' },
|
||||
{ title: 'Router', body: 'Ein FastAPI APIRouter, beim Start unter /api/v1 gemountet — aber nur für aktivierte Plugins.' },
|
||||
{ title: 'Tasks', body: 'Celery-Tasks für alles, was mit einem externen System spricht. Hooks reihen Arbeit ein; sie blockieren den Event-Loop nie mit I/O.' },
|
||||
{ title: 'Hooks', body: 'Handler, die über den @hook-Decorator am zentralen Event-Bus (hook_registry) registriert werden — reagieren auf Events wie poll.complete.' },
|
||||
],
|
||||
}
|
||||
|
||||
const BUILTIN: Record<'en' | 'de', BuiltinPlugin[]> = {
|
||||
en: [
|
||||
{ label: 'Wazuh Security', body: 'Syncs device enrollment with the Wazuh manager and surfaces agent health warnings, vulnerability counts, alerts, and CIS benchmark scores.', hosts: ['wazuh-manager:55000', 'wazuh-indexer:9200'], hooks: ['poll.complete'] },
|
||||
{ label: 'Graylog Syslog', body: 'Checks whether syslog-capable devices forward logs to Graylog via rsyslog, and can automatically write the forwarding rule.', hosts: [], hooks: [] },
|
||||
{ label: 'CrowdSec', body: 'Surfaces org-level CrowdSec security health data per device: active decisions, blocked requests, remediation metrics, and top attack scenarios.', hosts: ['admin.api.crowdsec.net:443'], hooks: [] },
|
||||
{ label: 'apt-cacher-ng', body: "Auto-detects apt-cacher-ng on Docker hosts and configures the APT proxy on devices within the same site so APT traffic is routed through the local cache.", hosts: [], hooks: ['poll.complete'] },
|
||||
{ label: 'EOL Tracking', body: "Flags devices running end-of-life or soon-to-be-end-of-life firmware/OS versions, checked daily against the endoflife.date API. Reuses the OS version already collected during polling — no extra device I/O.", hosts: ['endoflife.date'], hooks: [] },
|
||||
],
|
||||
de: [
|
||||
{ label: 'Wazuh Security', body: 'Synchronisiert die Geräte-Registrierung mit dem Wazuh-Manager und zeigt Agent-Health-Warnungen, Schwachstellenanzahl, Alerts und CIS-Benchmark-Scores an.', hosts: ['wazuh-manager:55000', 'wazuh-indexer:9200'], hooks: ['poll.complete'] },
|
||||
{ label: 'Graylog Syslog', body: 'Prüft, ob syslog-fähige Geräte Logs via rsyslog an Graylog weiterleiten, und kann die Weiterleitungsregel automatisch schreiben.', hosts: [], hooks: [] },
|
||||
{ label: 'CrowdSec', body: 'Zeigt Org-Level-CrowdSec-Sicherheitsdaten pro Gerät: aktive Entscheidungen, geblockte Requests, Remediation-Metriken und Top-Angriffsszenarien.', hosts: ['admin.api.crowdsec.net:443'], hooks: [] },
|
||||
{ label: 'apt-cacher-ng', body: 'Erkennt apt-cacher-ng automatisch auf Docker-Hosts und konfiguriert den APT-Proxy auf Geräten im selben Standort, sodass APT-Traffic über den lokalen Cache läuft.', hosts: [], hooks: ['poll.complete'] },
|
||||
{ label: 'EOL-Tracking', body: 'Kennzeichnet Geräte mit End-of-Life- oder bald End-of-Life-Firmware/OS, täglich gegen die endoflife.date-API geprüft. Nutzt die beim Polling bereits erfasste OS-Version — keine zusätzliche Geräte-I/O.', hosts: ['endoflife.date'], hooks: [] },
|
||||
],
|
||||
}
|
||||
|
||||
const STEPS: Record<'en' | 'de', Step[]> = {
|
||||
en: [
|
||||
{ title: 'Declare metadata', body: 'Create netork/plugins/<name>/__init__.py with a PluginMeta — permissions are declarative, for human review, not enforced at runtime.' },
|
||||
{ title: 'Register', body: 'Implement register(): register the metadata with plugin_registry and import your hooks module so its @hook decorators run.' },
|
||||
{ title: 'Add hooks', body: 'hooks.py: handlers for the events you care about, e.g. poll.complete.' },
|
||||
{ title: 'Add a router (optional)', body: 'router.py: a FastAPI APIRouter, returned from a _get_router() callable passed to plugin_registry.register().' },
|
||||
{ title: 'Add tasks', body: 'tasks.py: Celery tasks for anything that talks to an external system — hooks should queue work, not block.' },
|
||||
],
|
||||
de: [
|
||||
{ title: 'Metadaten deklarieren', body: 'netork/plugins/<name>/__init__.py mit einer PluginMeta anlegen — Permissions sind deklarativ, für menschliches Review, nicht zur Laufzeit erzwungen.' },
|
||||
{ title: 'Registrieren', body: 'register() implementieren: Metadaten bei plugin_registry registrieren und das hooks-Modul importieren, damit dessen @hook-Decorators laufen.' },
|
||||
{ title: 'Hooks hinzufügen', body: 'hooks.py: Handler für die Events, die dich interessieren, z. B. poll.complete.' },
|
||||
{ title: 'Router hinzufügen (optional)', body: 'router.py: ein FastAPI APIRouter, zurückgegeben von einem _get_router()-Callable, das an plugin_registry.register() übergeben wird.' },
|
||||
{ title: 'Tasks hinzufügen', body: 'tasks.py: Celery-Tasks für alles, was mit einem externen System spricht — Hooks sollten Arbeit einreihen, nicht blockieren.' },
|
||||
],
|
||||
}
|
||||
|
||||
const HOOK_KINDS: Record<'en' | 'de', HookKind[]> = {
|
||||
en: [
|
||||
{ name: 'fire()', behavior: 'Fire-and-forget. All handlers run concurrently; exceptions are logged, never raised. 30 s timeout per handler.', use: 'Side effects after an event, e.g. "queue a Wazuh sync after a poll completes".' },
|
||||
{ name: 'call()', behavior: 'Blocking. Handlers run sequentially by priority; exceptions propagate to the caller. 10 s timeout per handler.', use: 'When the caller needs to know a handler failed.' },
|
||||
{ name: 'transform()', behavior: 'Pipeline. Handlers run sequentially, each receiving and returning value. 10 s timeout per handler.', use: 'Letting plugins enrich or modify a value in place, e.g. annotate a payload before it\'s persisted.' },
|
||||
],
|
||||
de: [
|
||||
{ name: 'fire()', behavior: 'Fire-and-forget. Alle Handler laufen gleichzeitig; Exceptions werden geloggt, nie geworfen. 30 s Timeout pro Handler.', use: 'Seiteneffekte nach einem Event, z. B. „Wazuh-Sync nach abgeschlossenem Poll einreihen".' },
|
||||
{ name: 'call()', behavior: 'Blockierend. Handler laufen sequenziell nach Priorität; Exceptions propagieren zum Aufrufer. 10 s Timeout pro Handler.', use: 'Wenn der Aufrufer wissen muss, dass ein Handler fehlgeschlagen ist.' },
|
||||
{ name: 'transform()', behavior: 'Pipeline. Handler laufen sequenziell, jeder erhält und liefert value zurück. 10 s Timeout pro Handler.', use: 'Plugins lassen einen Wert anreichern oder verändern, z. B. ein Payload vor dem Speichern annotieren.' },
|
||||
],
|
||||
}
|
||||
|
||||
const REGISTRY_PARAGRAPHS: Record<'en' | 'de', string[]> = {
|
||||
en: [
|
||||
'Enabled state lives in the database as a Setting row: plugin.<name>.enabled = "true" | "false". The registry exposes enable() and disable(), gated behind RBAC like every other orchestration action.',
|
||||
"Routers are mounted once at FastAPI startup for every enabled plugin — FastAPI doesn't support unmounting routers at runtime, so disabling a plugin's API surface takes effect after the next deploy.",
|
||||
"Hook handlers are registered as soon as a plugin's hooks module is imported. Whether a handler is a no-op until configured is up to the handler itself — the bundled plugins check their own Settings before acting.",
|
||||
],
|
||||
de: [
|
||||
'Der Aktivierungsstatus liegt in der Datenbank als Setting-Zeile: plugin.<name>.enabled = "true" | "false". Die Registry stellt enable() und disable() bereit, abgesichert durch RBAC wie jede andere Orchestrierungsaktion.',
|
||||
'Router werden beim FastAPI-Start einmal für jedes aktivierte Plugin gemountet — FastAPI unterstützt kein Unmounten zur Laufzeit, daher wirkt sich das Deaktivieren der API-Oberfläche eines Plugins erst nach dem nächsten Deploy aus.',
|
||||
'Hook-Handler werden registriert, sobald das hooks-Modul eines Plugins importiert wird. Ob ein Handler bis zur Konfiguration ein No-op bleibt, entscheidet der Handler selbst — die mitgelieferten Plugins prüfen vor dem Handeln ihre eigenen Settings.',
|
||||
],
|
||||
}
|
||||
|
||||
const META_CODE = `# netork/plugins/myplugin/__init__.py
|
||||
from netork.plugins import PluginMeta, PluginPermissions
|
||||
from netork.plugins.registry import plugin_registry
|
||||
|
||||
_META = PluginMeta(
|
||||
name="myplugin",
|
||||
label="My Plugin",
|
||||
description="What this plugin does.",
|
||||
permissions=PluginPermissions(
|
||||
db_read=["Device"],
|
||||
external_hosts=["myservice:443"],
|
||||
hooks=["poll.complete"],
|
||||
),
|
||||
)
|
||||
|
||||
def register() -> None:
|
||||
plugin_registry.register(_META, get_router=_get_router)
|
||||
from netork.plugins.myplugin import hooks as _hooks # noqa: F401
|
||||
|
||||
def _get_router():
|
||||
from netork.plugins.myplugin.router import router
|
||||
return router`
|
||||
import { CodeBlock, H2, PageHeader, RuleList, Section, Split } from '../components/ui'
|
||||
|
||||
const HOOK_CODE = `# netork/plugins/myplugin/hooks.py
|
||||
from netork.plugins.hooks import hook
|
||||
|
||||
@hook("poll.complete", priority=100)
|
||||
async def _on_poll_complete(device_id: str, session_factory, **kwargs) -> None:
|
||||
async def on_poll_complete(device_id: str, **kwargs) -> None:
|
||||
# queue a Celery task — never block the event loop here
|
||||
from netork.plugins.myplugin.tasks import sync_device
|
||||
sync_device.apply_async(args=[device_id], queue="default")`
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
return (
|
||||
<pre className="rounded-xl border border-slate-800 bg-slate-900 p-6 font-mono text-sm text-slate-300 overflow-x-auto">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function HostTag({ value }: { value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono text-slate-400 border border-slate-700 bg-slate-950">
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Plugins() {
|
||||
const { lang, t } = useLang()
|
||||
const blocks = BUILDING_BLOCKS[lang]
|
||||
const builtin = BUILTIN[lang]
|
||||
const steps = STEPS[lang]
|
||||
const hookKinds = HOOK_KINDS[lang]
|
||||
const registryParagraphs = REGISTRY_PARAGRAPHS[lang]
|
||||
const { t } = useLang()
|
||||
const p = t.plugins
|
||||
usePageTitle(t.meta.plugins)
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<>
|
||||
<PageHeader eyebrow={p.eyebrow} title={p.title} lead={linkify(p.lead)} />
|
||||
|
||||
<div className="mb-16">
|
||||
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
|
||||
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
|
||||
<Section className="pt-0 md:pt-0">
|
||||
<Split aside={<H2>{p.builtinTitle}</H2>}>
|
||||
<RuleList
|
||||
items={p.builtin.map((b) => ({
|
||||
term: b.title,
|
||||
aside: b.hosts ? <span className="font-mono text-xs font-normal text-ink-muted">{b.hosts}</span> : undefined,
|
||||
body: linkify(b.body),
|
||||
}))}
|
||||
/>
|
||||
</Split>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<Split aside={<H2>{p.buildTitle}</H2>}>
|
||||
<RuleList items={p.build.map((b) => ({ term: b.title, body: linkify(b.body) }))} />
|
||||
</Split>
|
||||
<div className="mt-14 grid gap-8 lg:grid-cols-12">
|
||||
<div className="lg:col-span-5">
|
||||
<p className="text-h3">{p.codeTitle}</p>
|
||||
<p className="mt-2 text-ink-soft">{linkify(p.enable)}</p>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.plugins.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-2xl">{linkify(t.plugins.sub)}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.whatHeading}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{blocks.map((b) => (
|
||||
<div key={b.title} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{b.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(b.body)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.builtinHeading}
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{builtin.map((p) => (
|
||||
<div key={p.label} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-semibold text-slate-200 mb-1.5">{p.label}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed mb-3">{linkify(p.body)}</p>
|
||||
{(p.hosts.length > 0 || p.hooks.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{p.hosts.map((h) => <HostTag key={h} value={h} />)}
|
||||
{p.hooks.map((h) => <HostTag key={h} value={`hook: ${h}`} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.writingHeading}
|
||||
</h2>
|
||||
<ol className="space-y-4 mb-8">
|
||||
{steps.map((s, i) => (
|
||||
<li key={s.title} className="flex gap-4">
|
||||
<span className="shrink-0 h-6 w-6 rounded-full bg-sky-600/10 text-sky-400 text-xs font-semibold flex items-center justify-center mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-200">{s.title}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(s.body)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="space-y-4">
|
||||
<CodeBlock code={META_CODE} />
|
||||
<div className="min-w-0 lg:col-span-7">
|
||||
<CodeBlock code={HOOK_CODE} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.hookBusHeading}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{hookKinds.map((h) => (
|
||||
<div key={h.name} className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<p className="text-sm font-mono font-semibold text-sky-400 mb-1.5">{h.name}</p>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-2">{linkify(h.behavior)}</p>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(h.use)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-20">
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{t.plugins.registryHeading}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{registryParagraphs.map((p) => (
|
||||
<p key={p} className="text-sm text-slate-400 leading-relaxed">{linkify(p)}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-3">{t.plugins.cta.heading}</h2>
|
||||
<p className="text-sm text-slate-400 leading-relaxed mb-6 max-w-md mx-auto">{t.plugins.cta.body}</p>
|
||||
<Link
|
||||
to="/docs/getting-started"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
{t.plugins.cta.button}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,222 +0,0 @@
|
||||
import { useLang } from '../context/LangContext'
|
||||
import { linkify } from '../lib/glossary'
|
||||
|
||||
type Item = { title: string; detail: string; nis2?: boolean }
|
||||
type Group = { label: string; items: Item[] }
|
||||
|
||||
const GROUPS: Record<'en' | 'de', Group[]> = {
|
||||
en: [
|
||||
{
|
||||
label: 'Next release',
|
||||
items: [
|
||||
{
|
||||
title: 'CrowdSec across sites',
|
||||
detail: 'The CrowdSec plugin grows into its own section: every LAPI instance, decisions and alerts across sites, how many sites one address reached, bans inside your own subnets counted separately, and which internet-facing hosts nobody watches yet.',
|
||||
},
|
||||
{
|
||||
title: 'Windows driver',
|
||||
detail: 'Windows hosts over WinRM: facts, interfaces, ARP, routes and services, including service control.',
|
||||
},
|
||||
{
|
||||
title: 'Single-use console tickets',
|
||||
detail: 'The browser terminal opens with a one-time ticket instead of passing the session token in the WebSocket URL.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Honest reboots',
|
||||
detail: 'A reboot request for a device whose driver cannot restart it is refused with a reason instead of being reported as done.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Planned',
|
||||
items: [
|
||||
{
|
||||
title: 'Compliance dashboard',
|
||||
detail: 'Per-site Art. 21 checklist: asset coverage, patch status, security agent deployment, config drift, syslog forwarding, audit activity — aggregated into a single view.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Webhook engine',
|
||||
detail: 'Outbound HTTP webhooks for events: device discovered, config change detected, job failed, alert threshold exceeded. HMAC-SHA256 payload signing. Integrates with n8n, Slack, PagerDuty.',
|
||||
},
|
||||
{
|
||||
title: 'Live job log streaming',
|
||||
detail: 'WebSocket endpoint for real-time Celery task output in the UI. Currently streamed via SSE for fix-flows; full job log streaming for all long-running operations.',
|
||||
},
|
||||
{
|
||||
title: 'NetBox sync — manual trigger & status view',
|
||||
detail: 'POST /api/v1/netbox/sync endpoint with progress feedback and a sync history view in the UI. Currently sync runs automatically; the manual trigger and status are missing.',
|
||||
},
|
||||
{
|
||||
title: 'HashiCorp Vault integration',
|
||||
detail: 'Real secret management as the first security plugin, replacing the current Fernet-based encryption at rest for device credentials and SSH keys.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Firewall profile management — next steps',
|
||||
detail: 'Per-site profiles with a diff against a live OPNsense and step-by-step apply shipped in 0.12. Profile types, OpenWrt as a target, and the push mechanism beyond that are still under review.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Under consideration',
|
||||
items: [
|
||||
{
|
||||
title: 'VLAN visualization',
|
||||
detail: 'Heatmap or matrix view of which devices carry which VLANs, without digging through per-device VLAN lists.',
|
||||
},
|
||||
{
|
||||
title: 'Incident workflow',
|
||||
detail: 'Structured incident record tied to devices and security events. Deadline tracker for NIS2 Art. 23 reporting windows (24 h early warning, 72 h full notification). Webhook to external ticketing systems.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'mDNS scanner',
|
||||
detail: 'Discover media devices (Apple TV, Chromecast, Sonos) via mDNS/Bonjour without needing a NAPALM driver. Inventory visibility and firewall segmentation suggestions.',
|
||||
},
|
||||
{
|
||||
title: 'Prometheus metrics + Grafana dashboards',
|
||||
detail: 'Expose per-device SNMP health metrics as a Prometheus scrape endpoint. Pre-built Grafana dashboard for interface counters, CPU, memory, and poll lag.',
|
||||
},
|
||||
{
|
||||
title: 'Kubernetes Helm chart',
|
||||
detail: 'Helm chart for production-grade deployments: horizontal scaling for the API and worker pods, Secrets via Vault Agent Injector, Ingress with TLS termination.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
de: [
|
||||
{
|
||||
label: 'Nächstes Release',
|
||||
items: [
|
||||
{
|
||||
title: 'CrowdSec über alle Standorte',
|
||||
detail: 'Das CrowdSec-Plugin wird ein eigener Bereich: jede LAPI-Instanz, Entscheidungen und Alerts über alle Standorte, wie viele Standorte eine Adresse erreicht hat, Sperren im eigenen Netz getrennt gezählt, und welche vom Internet erreichbaren Hosts noch niemand überwacht.',
|
||||
},
|
||||
{
|
||||
title: 'Windows-Treiber',
|
||||
detail: 'Windows-Hosts über WinRM: Fakten, Interfaces, ARP, Routen und Dienste, inklusive Dienststeuerung.',
|
||||
},
|
||||
{
|
||||
title: 'Einmal-Tickets für die Konsole',
|
||||
detail: 'Das Browser-Terminal öffnet mit einem einmal gültigen Ticket, statt das Sitzungstoken in der WebSocket-URL mitzugeben.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Ehrliche Neustarts',
|
||||
detail: 'Eine Neustart-Anfrage für ein Gerät, dessen Treiber es nicht neu starten kann, wird mit Begründung abgelehnt, statt als erledigt gemeldet zu werden.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Geplant',
|
||||
items: [
|
||||
{
|
||||
title: 'Compliance-Dashboard',
|
||||
detail: 'Art. 21-Checkliste pro Standort: Asset-Abdeckung, Patch-Status, Security-Agent-Deployment, Config-Drift, Syslog-Weiterleitung, Audit-Aktivität — zusammengefasst in einer Ansicht.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Webhook-Engine',
|
||||
detail: 'Ausgehende HTTP-Webhooks für Events: Gerät entdeckt, Konfigurationsänderung erkannt, Job fehlgeschlagen, Warnschwelle überschritten. HMAC-SHA256-Payload-Signierung. Integrierbar mit n8n, Slack, PagerDuty.',
|
||||
},
|
||||
{
|
||||
title: 'Live-Job-Log-Streaming',
|
||||
detail: 'WebSocket-Endpunkt für Echtzeit-Celery-Task-Output in der UI. Aktuell wird via SSE für Fix-Flows gestreamt; vollständiges Job-Log-Streaming für alle lang laufenden Operationen geplant.',
|
||||
},
|
||||
{
|
||||
title: 'NetBox-Sync — manueller Trigger & Statusansicht',
|
||||
detail: 'POST /api/v1/netbox/sync-Endpunkt mit Fortschrittsfeedback und Sync-Verlaufsansicht in der UI. Derzeit läuft der Sync automatisch; manueller Trigger und Status fehlen noch.',
|
||||
},
|
||||
{
|
||||
title: 'HashiCorp-Vault-Integration',
|
||||
detail: 'Echtes Secret-Management als erstes Security-Plugin — löst die aktuelle Fernet-basierte Verschlüsselung von Geräte-Credentials und SSH-Schlüsseln ab.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'Firewall-Profile — nächste Schritte',
|
||||
detail: 'Profile pro Standort mit Diff gegen eine echte OPNsense und schrittweisem Anwenden kamen mit 0.12. Profiltypen, OpenWrt als Ziel und der Push-Mechanismus darüber hinaus stehen noch auf dem Prüfstand.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'In Erwägung',
|
||||
items: [
|
||||
{
|
||||
title: 'VLAN-Visualisierung',
|
||||
detail: 'Heatmap- oder Matrixansicht, welche Geräte welche VLANs führen — ohne sich durch geräteweise VLAN-Listen zu graben.',
|
||||
},
|
||||
{
|
||||
title: 'Incident-Workflow',
|
||||
detail: 'Strukturierter Incident-Datensatz, verknüpft mit Geräten und Sicherheitsereignissen. Fristen-Tracker für NIS2 Art. 23 Meldepflichten (24 h Frühwarnung, 72 h vollständige Meldung). Webhook zu externen Ticketing-Systemen.',
|
||||
nis2: true,
|
||||
},
|
||||
{
|
||||
title: 'mDNS-Scanner',
|
||||
detail: 'Entdeckt Mediengeräte (Apple TV, Chromecast, Sonos) über mDNS/Bonjour ohne NAPALM-Treiber. Inventarsichtbarkeit und Empfehlungen zur Firewall-Segmentierung.',
|
||||
},
|
||||
{
|
||||
title: 'Prometheus-Metriken + Grafana-Dashboards',
|
||||
detail: 'Veröffentlicht gerätebezogene SNMP-Gesundheitsmetriken als Prometheus-Scrape-Endpunkt. Vorgefertigtes Grafana-Dashboard für Schnittstellenzähler, CPU, Speicher und Poll-Lag.',
|
||||
},
|
||||
{
|
||||
title: 'Kubernetes Helm Chart',
|
||||
detail: 'Helm Chart für produktionsreife Deployments: horizontale Skalierung für API- und Worker-Pods, Secrets via Vault Agent Injector, Ingress mit TLS-Terminierung.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function Nis2Badge() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-mono font-semibold bg-sky-500/10 text-sky-400 border border-sky-500/20">
|
||||
NIS2
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Roadmap() {
|
||||
const { lang, t } = useLang()
|
||||
const groups = GROUPS[lang]
|
||||
|
||||
return (
|
||||
<div className="py-16 md:py-24">
|
||||
<div className="max-w-4xl mx-auto px-6">
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">{t.roadmap.heading}</h1>
|
||||
<p className="text-base text-slate-400 leading-relaxed max-w-xl">
|
||||
{t.roadmap.sub.slice(0, t.roadmap.sub.indexOf('NIS2'))}
|
||||
<Nis2Badge />
|
||||
{t.roadmap.sub.slice(t.roadmap.sub.indexOf('NIS2') + 'NIS2'.length)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-14">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h2 className="text-xl font-semibold text-slate-200 mb-6 pb-2 border-b border-slate-800">
|
||||
{group.label}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.title} className="flex gap-4">
|
||||
<div className="mt-2 shrink-0 h-1.5 w-1.5 rounded-full bg-sky-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-slate-200">{item.title}</span>
|
||||
{item.nis2 && <Nis2Badge />}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">{linkify(item.detail)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||