diff --git a/.gitignore b/.gitignore index e4587c7..61557c5 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md index 50b1c70..f924eb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4a1d578..16970d2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 `` (nav, hero) - -```tsx -netOrk -``` - -For the nav, pair it with the wordmark: - -```tsx - - - - netOrk - - -``` - -Use `netOrk` consistently — the `Ork` part -in sky-400 ties the wordmark to the accent color. - -### `` references - -```html - - -``` - ---- - -## 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 (`` 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 - - active - +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 - - offline - +- 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 - - warning - -``` +## Wordmark ---- +Text only: `netOrk` 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 - - Get started - -``` - -### Secondary / Ghost Button - -```tsx - - See all features - -``` - -### Feature Card - -```tsx -
-
- {/* Heroicon SVG, className="h-5 w-5 text-sky-400" */} -
-

Feature name

-

- Description of the feature in one to three sentences. -

-
-``` - -### Screenshot Frame - -App screenshots must be wrapped in this frame to integrate naturally -against the dark background: - -```tsx -
- Device inventory -
-``` - -**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 -
- - - - netork / devices -
-``` - -### Code Block - -```tsx -
-  {`bash scripts/deploy.sh 192.168.1.1`}
-
-``` - -### Driver / Integration Badge - -```tsx - - OpenWRT - -``` - -### Section Divider - -```tsx -
-``` - ---- - -## 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 `` 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. diff --git a/docs/PAGES.md b/docs/PAGES.md index ab04f3d..8ad4519 100644 --- a/docs/PAGES.md +++ b/docs/PAGES.md @@ -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 `
`) + +`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 - ``` - -3. **First run** - - Navigate to `http://` - - 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. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index bfede83..824422a 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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, diff --git a/index.html b/index.html index 9d43285..3605869 100644 --- a/index.html +++ b/index.html @@ -1,14 +1,15 @@ - + - - netOrk — Network Orchestration Platform + + + netOrk — Control instead of drift - - + +
diff --git a/nginx.conf b/nginx.conf index 018bfab..cd06a6b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; diff --git a/package-lock.json b/package-lock.json index 047880b..90a7f43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ diff --git a/package.json b/package.json index 69f85dc..008dd82 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/screenshots/audit-log.webp b/public/screenshots/audit-log.webp index b6cd991..3fb6d71 100644 Binary files a/public/screenshots/audit-log.webp and b/public/screenshots/audit-log.webp differ diff --git a/public/screenshots/dashboard.webp b/public/screenshots/dashboard.webp deleted file mode 100644 index 3656a7f..0000000 Binary files a/public/screenshots/dashboard.webp and /dev/null differ diff --git a/public/screenshots/device-detail.webp b/public/screenshots/device-detail.webp deleted file mode 100644 index 076b843..0000000 Binary files a/public/screenshots/device-detail.webp and /dev/null differ diff --git a/public/screenshots/device-security.webp b/public/screenshots/device-security.webp deleted file mode 100644 index f9d956c..0000000 Binary files a/public/screenshots/device-security.webp and /dev/null differ diff --git a/public/screenshots/devices.webp b/public/screenshots/devices.webp deleted file mode 100644 index f625b57..0000000 Binary files a/public/screenshots/devices.webp and /dev/null differ diff --git a/public/screenshots/drift-narrow.webp b/public/screenshots/drift-narrow.webp new file mode 100644 index 0000000..989708e Binary files /dev/null and b/public/screenshots/drift-narrow.webp differ diff --git a/public/screenshots/drift.webp b/public/screenshots/drift.webp new file mode 100644 index 0000000..534db27 Binary files /dev/null and b/public/screenshots/drift.webp differ diff --git a/public/screenshots/service-checks.webp b/public/screenshots/service-checks.webp deleted file mode 100644 index ab5f1e2..0000000 Binary files a/public/screenshots/service-checks.webp and /dev/null differ diff --git a/public/screenshots/vlans.webp b/public/screenshots/vlans.webp deleted file mode 100644 index 4846320..0000000 Binary files a/public/screenshots/vlans.webp and /dev/null differ diff --git a/public/screenshots/vulnerabilities.webp b/public/screenshots/vulnerabilities.webp index 51e8575..b340285 100644 Binary files a/public/screenshots/vulnerabilities.webp and b/public/screenshots/vulnerabilities.webp differ diff --git a/scripts/check/site.py b/scripts/check/site.py new file mode 100644 index 0000000..79efd5c --- /dev/null +++ b/scripts/check/site.py @@ -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
(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()) diff --git a/scripts/demo/README.md b/scripts/demo/README.md index d99bd63..1f1c165 100644 --- a/scripts/demo/README.md +++ b/scripts/demo/README.md @@ -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`. diff --git a/scripts/demo/anonymize.py b/scripts/demo/anonymize.py index ba85cc0..eaeabce 100755 --- a/scripts/demo/anonymize.py +++ b/scripts/demo/anonymize.py @@ -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") diff --git a/scripts/screenshots/capture.py b/scripts/screenshots/capture.py index 977f524..7cb505e 100644 --- a/scripts/screenshots/capture.py +++ b/scripts/screenshots/capture.py @@ -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= --var server= [--only name ...] + capture.py --var ap= [--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]}=") 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)): diff --git a/scripts/screenshots/shots.py b/scripts/screenshots/shots.py index d31ef45..8e4fdb8 100644 --- a/scripts/screenshots/shots.py +++ b/scripts/screenshots/shots.py @@ -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"), ] diff --git a/src/App.tsx b/src/App.tsx index 3aeac62..bccc52b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( -
+ +