feat: initial website implementation
CI / TypeScript — type-check (push) Successful in 8s
CI / Publish — build & push image (push) Successful in 11s
CI / Deploy — pull & restart on host (push) Failing after 3s

React 18 + TypeScript + Vite + Tailwind CSS website for netOrk.
Built by a parallel instance based on the product docs.

Pages: Home (landing), Features, Drivers, Getting Started
Components: Nav, Footer
Assets: public/logo.png (1024x1024 RGBA)
Docker: multi-stage Dockerfile (node:20 builder + nginx:alpine)
Compose: netork-website service on proxy-net (Zoraxy integration)

docs/DESIGN.md: Logo Assets section added (PNG only, SVG removed);
wordmark pattern and <head> references documented.
.gitignore: fixed (was literal \n, now proper newlines); excludes
node_modules/, dist/, memory/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Manivong
2026-06-28 10:19:50 +02:00
co-authored by Claude Sonnet 4.6
parent 00c483c2a2
commit efa01b5f41
26 changed files with 4118 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.gitignore
*.md
+79
View File
@@ -0,0 +1,79 @@
name: CI
on:
push:
branches: ['**']
tags: ['v*']
pull_request:
branches: ['**']
jobs:
typecheck:
name: TypeScript — type-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install
run: npm ci
- name: Type-check (tsc)
run: npx tsc --noEmit
publish:
name: Publish — build & push image
runs-on: ubuntu-latest
needs: [typecheck]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Login to registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.netork.io -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build & push
run: |
SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.netork.io/netork/website:latest \
-t registry.netork.io/netork/website:main-${SHA} \
.
docker push registry.netork.io/netork/website:latest
docker push registry.netork.io/netork/website:main-${SHA}
- name: Logout
if: always()
run: docker logout registry.netork.io
deploy:
name: Deploy — pull & restart on host
runs-on: ubuntu-latest
needs: [publish]
steps:
- uses: actions/checkout@v4
- name: Write SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd /opt/netork-website && \
echo '${{ secrets.REGISTRY_PASSWORD }}' | docker login registry.netork.io -u '${{ secrets.REGISTRY_USER }}' --password-stdin && \
docker compose pull && \
docker compose up -d --remove-orphans && \
docker logout registry.netork.io"
- name: Clean up SSH key
if: always()
run: rm -f ~/.ssh/deploy_key
+7 -1
View File
@@ -1 +1,7 @@
node_modules/\ndist/\n.env\n.env.local\n*.local\n.DS_Store
node_modules/
dist/
memory/
.env
.env.local
*.local
.DS_Store
+14
View File
@@ -0,0 +1,14 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Serve stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+16
View File
@@ -0,0 +1,16 @@
services:
netork-website:
build: .
image: netork-website:latest
container_name: netork-website
restart: unless-stopped
expose:
- "80"
networks:
- proxy-net
# Zoraxy proxies to netork-website:80 via proxy-net.
# No host port binding needed — SSL terminated by Zoraxy.
networks:
proxy-net:
external: true
+37
View File
@@ -6,6 +6,43 @@ between the product UI and the marketing site.
---
## Logo Assets
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.**
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en" class="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>
<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." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
gzip_vary on;
# SPA fallback — all routes resolve to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Immutable cache for hashed assets (Vite adds content hashes)
location ~* \.(js|css|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.(png|jpg|jpeg|gif|svg|ico|webp)$ {
expires 30d;
add_header Cache-Control "public";
}
}
+2771
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "netork-website",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@heroicons/react": "^2.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File
+26
View File
@@ -0,0 +1,26 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Nav from './components/Nav'
import Footer from './components/Footer'
import Home from './pages/Home'
import Features from './pages/Features'
import Drivers from './pages/Drivers'
import GettingStarted from './pages/GettingStarted'
export default function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-slate-950 flex 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 />} />
</Routes>
</main>
<Footer />
</div>
</BrowserRouter>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { Link } from 'react-router-dom'
export default function Footer() {
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>
</Link>
<p className="text-sm text-slate-500">Self-hosted network orchestration</p>
</div>
<div className="grid grid-cols-3 gap-10 flex-1 text-sm">
<div>
<p className="font-medium text-slate-400 mb-3">Links</p>
<ul className="space-y-2">
<li><Link to="/features" className="text-slate-500 hover:text-slate-100 transition-colors">Features</Link></li>
<li><Link to="/drivers" className="text-slate-500 hover:text-slate-100 transition-colors">Drivers</Link></li>
<li><Link to="/plugins" className="text-slate-500 hover:text-slate-100 transition-colors">Plugins</Link></li>
</ul>
</div>
<div>
<p className="font-medium text-slate-400 mb-3">Resources</p>
<ul className="space-y-2">
<li><Link to="/docs/getting-started" className="text-slate-500 hover:text-slate-100 transition-colors">Getting Started</Link></li>
<li><Link to="/docs/architecture" className="text-slate-500 hover:text-slate-100 transition-colors">Architecture</Link></li>
<li><span className="text-slate-600">Changelog</span></li>
</ul>
</div>
<div>
<p className="font-medium text-slate-400 mb-3">Legal</p>
<ul className="space-y-2">
<li><span className="text-slate-600">MIT License</span></li>
<li><span className="text-slate-600">Privacy (none collected)</span></li>
</ul>
</div>
</div>
</div>
</div>
</footer>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState, useRef, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
export default function Nav() {
const [docsOpen, setDocsOpen] = useState(false)
const location = useLocation()
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDocsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
useEffect(() => {
setDocsOpen(false)
}, [location.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')
return (
<nav className="sticky top-0 z-10 bg-slate-900/80 backdrop-blur border-b border-slate-800">
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center gap-8">
<Link to="/" className="flex items-center gap-2.5 mr-2">
<img src="/logo.png" alt="" className="h-7 w-7" aria-hidden="true" />
<span className="font-semibold text-slate-100 tracking-tight">
net<span className="text-sky-400">Ork</span>
</span>
</Link>
<Link to="/features" className={linkCls('/features')}>
Features
</Link>
<Link to="/drivers" className={linkCls('/drivers')}>
Drivers
</Link>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDocsOpen((v) => !v)}
className={`flex items-center gap-1 text-sm transition-colors ${
isDocsActive ? 'text-slate-100' : 'text-slate-400 hover:text-slate-100'
}`}
>
Docs
<ChevronDownIcon
className={`h-4 w-4 transition-transform ${docsOpen ? 'rotate-180' : ''}`}
/>
</button>
{docsOpen && (
<div className="absolute top-full mt-2 left-0 w-48 rounded-lg border border-slate-800 bg-slate-900 py-1 shadow-xl">
<Link
to="/docs/getting-started"
className="block px-4 py-2 text-sm text-slate-400 hover:text-slate-100 hover:bg-slate-800 transition-colors"
>
Getting Started
</Link>
<Link
to="/docs/architecture"
className="block px-4 py-2 text-sm text-slate-400 hover:text-slate-100 hover:bg-slate-800 transition-colors"
>
Architecture
</Link>
</div>
)}
</div>
<Link to="/plugins" className={linkCls('/plugins')}>
Plugins
</Link>
<div className="ml-auto">
<Link
to="/docs/getting-started"
className="inline-flex items-center gap-2 px-4 py-1.5 text-sm rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors"
>
Get started
</Link>
</div>
</div>
</nav>
)
}
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-950 text-slate-100;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+133
View File
@@ -0,0 +1,133 @@
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: 'openwrt', deviceType: 'OpenWRT access points', facts: '✓', interfaces: '✓', lldp: '—', vlans: '✓', ssids: '✓', health: '✓', docker: '—', reboot: '✓', configPush: '✓', status: 'stable' },
{ name: 'opnsense', deviceType: 'OPNsense firewalls', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'proxmox', deviceType: 'Proxmox VE hypervisors', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'linux', deviceType: 'Generic Linux servers', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'procurve', deviceType: 'HP ProCurve / Aruba switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '✓', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'tplink_jetstream', deviceType: 'TP-Link Jetstream switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'netgear', deviceType: 'Netgear switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
{ name: 'fritzbox', deviceType: 'AVM Fritz!Box routers', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '✓', health: '—', docker: '—', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'zyxel', deviceType: 'Zyxel switches', facts: '✓', interfaces: '✓', lldp: '✓', vlans: '✓', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
{ name: 'openmediavault', deviceType: 'OpenMediaVault NAS', facts: '✓', interfaces: '✓', lldp: '—', vlans: '—', ssids: '—', health: '✓', docker: '✓', reboot: '✓', configPush: '—', status: 'stable' },
{ name: 'sonos', deviceType: 'Sonos speakers', facts: '✓', interfaces: '—', lldp: '—', vlans: '—', ssids: '—', health: '—', docker: '—', reboot: '—', configPush: '—', status: 'beta' },
]
const NAPALM_BUILTIN = [
'Cisco IOS',
'Cisco IOS-XE',
'Cisco NX-OS',
'Arista EOS',
'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() {
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">Supported Devices</h1>
<p className="text-base text-slate-400 leading-relaxed max-w-xl">
netOrk ships with custom NAPALM drivers for 11 device types and supports all built-in NAPALM
drivers. Capability availability varies by driver.
</p>
</div>
{/* Custom drivers table */}
<div className="mb-16 overflow-x-auto">
<h2 className="text-xl font-semibold text-slate-200 mb-4">Custom Drivers</h2>
<div className="rounded-xl border border-slate-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800 bg-slate-900">
<th className="px-4 py-3 text-left font-medium text-slate-500">Driver</th>
<th className="px-4 py-3 text-left font-medium text-slate-500">Device type</th>
{CAPS.map((c) => (
<th key={c.key} className="px-3 py-3 text-center font-medium text-slate-500 whitespace-nowrap">
{c.label}
</th>
))}
<th className="px-4 py-3 text-left font-medium text-slate-500">Status</th>
</tr>
</thead>
<tbody>
{DRIVERS.map((d) => (
<tr key={d.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40 transition-colors">
<td className="px-4 py-3 font-mono text-sky-400 whitespace-nowrap">{d.name}</td>
<td className="px-4 py-3 text-slate-300 whitespace-nowrap">{d.deviceType}</td>
{CAPS.map((c) => (
<td key={c.key} className="px-3 py-3 text-center">
<span className={d[c.key] === '✓' ? 'text-green-400' : 'text-slate-700'}>
{d[c.key]}
</span>
</td>
))}
<td className="px-4 py-3"><StatusBadge status={d.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* NAPALM built-ins */}
<div>
<h2 className="text-xl font-semibold text-slate-200 mb-4">Built-in NAPALM Drivers</h2>
<p className="text-sm text-slate-400 mb-6">
All standard NAPALM drivers are supported. Capabilities depend on the upstream NAPALM implementation.
</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>
)
}
+160
View File
@@ -0,0 +1,160 @@
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" />
{item}
</li>
))}
</ul>
</div>
)
}
const SECTIONS = [
{
title: 'Device Management',
items: [
'CRUD for devices with credential profiles and SSH key management',
'Per-device poll intervals (minutes) or manual-only',
'Status tracking: planned / staged / active / decommissioning / offline / disabled',
'Vendor / model / OS auto-populated from NAPALM get_facts()',
'Site assignment with FK to structured Site records',
'AP Profile assignment for grouped OpenWRT config',
],
},
{
title: 'Discovery',
items: [
'ICMP ping sweep, SNMP scan, HTTP/HTTPS probing',
'Device fingerprinting: vendor + platform confidence scoring',
'FQDN resolution (reverse DNS)',
'Manual adoption from scan results — no auto-create to avoid inventory noise',
],
},
{
title: 'Supported Drivers',
items: [
'openwrt — OpenWRT access points',
'opnsense — OPNsense firewalls',
'proxmox — Proxmox VE hypervisors',
'linux — Generic Linux servers',
'procurve — HP ProCurve / Aruba switches',
'tplink_jetstream — TP-Link Jetstream managed switches',
'netgear — Netgear switches',
'fritzbox — AVM Fritz!Box routers',
'zyxel — Zyxel switches',
'openmediavault — OpenMediaVault NAS',
'sonos — Sonos speakers',
'Plus all built-in NAPALM drivers: Cisco IOS / IOS-XE / NX-OS, Arista EOS, Juniper JunOS',
],
},
{
title: 'Networking & Inventory',
items: [
'Interface browser with IPv4/IPv6 addresses, MAC, speed, MTU',
'LLDP neighbor discovery and topology graph',
'ARP table and DHCP lease browser per device',
'Subnet browser with interface-to-subnet assignments',
'VLAN list grouped by site; per-VLAN device membership view',
'SSID management with push to OpenWRT APs via UCI',
],
},
{
title: 'Configuration Management & Drift',
items: [
'Config drift detection: desired state (DB) vs device state (poll snapshot)',
'One-click drift fix stream with live SSH output in the browser',
'UCI-based config push for OpenWRT (VLAN names, SSID settings, radio config)',
'AP profile system: country code, HT/VHT mode, 802.11r, NTP, syslog, SSH port',
],
},
{
title: 'Scheduled Operations',
items: [
'Scheduled reboots for OpenWRT APs with per-site concurrency lock',
'Failback cron script written to device for netOrk-unreachable scenarios',
'Scheduled config drift fixes with time-window enforcement',
'Package update scheduling and one-click apply',
],
},
{
title: 'Monitoring & Health',
items: [
'SNMP health metrics (CPU, memory, interface counters) via get_health_metrics()',
'Per-device warning system with severity levels (error / warning / info)',
'Docker container and image status (Proxmox/Linux)',
'Service status and start/stop/restart (systemd)',
'VM/container list with OS device cross-linking (Proxmox)',
],
},
{
title: 'Security Integrations',
items: [
'Wazuh — agent enrollment tracking, vulnerability counts by severity, recent alert history, CIS benchmark scores, one-click agent install fix stream',
'Graylog — rsyslog forwarding status per device, one-click fix to write rule',
'CrowdSec — org-level decisions, remediation metrics, top attack scenarios',
],
},
{
title: 'DNS Management',
items: [
'DNS zone management with authoritative device assignment',
'Forward record provisioning (A records from device interfaces)',
'PTR record provisioning to reverse zones',
'Pending job queue for zone changes when device is unreachable',
],
},
{
title: 'Access Control (RBAC)',
items: [
'JWT authentication with remember-me (localStorage) or session-only (sessionStorage)',
'RBAC with four built-in roles: viewer / operator / engineer / administrator',
'Custom roles with any permission combination',
'Full audit log of all orchestration actions',
],
},
{
title: 'NetBox Sync',
items: [
'Pushes vendor, model, OS version, status to NetBox dcim.devices',
'Syncs interfaces, IP addresses, prefixes, VLANs',
'VM interfaces and disks for Proxmox hosts',
],
},
{
title: 'Developer Experience',
items: [
'OpenAPI / Swagger at /docs',
'Plugin system: new integrations follow a documented pattern',
'Celery task queue with dedicated queues per workload type',
'Docker Compose deployment (single command)',
'Alembic migrations run automatically on deploy',
],
},
]
export default function Features() {
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">Features</h1>
<p className="text-base text-slate-400 leading-relaxed max-w-xl">
Complete reference for all netOrk capabilities.
</p>
</div>
<div className="grid md:grid-cols-2 gap-12">
{SECTIONS.map((s) => (
<Section key={s.title} title={s.title} items={s.items} />
))}
</div>
</div>
</div>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { Link } from 'react-router-dom'
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 Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
return (
<div className="flex gap-6">
<div className="shrink-0 flex h-8 w-8 items-center justify-center rounded-full bg-sky-600/20 text-sky-400 text-sm font-semibold">
{n}
</div>
<div className="flex-1 pb-10">
<h2 className="text-xl font-semibold text-slate-200 mb-4">{title}</h2>
{children}
</div>
</div>
)
}
export default function GettingStarted() {
return (
<div className="py-16 md:py-24">
<div className="max-w-3xl mx-auto px-6">
<div className="mb-12">
<p className="text-sm font-medium text-sky-400 mb-2">Documentation</p>
<h1 className="text-4xl md:text-5xl font-bold text-slate-100 mb-4">Getting Started</h1>
<p className="text-base text-slate-400 leading-relaxed">
Get netOrk running from zero to a managed device in under 10 minutes.
</p>
</div>
<div className="border-t border-slate-800 pt-10">
<Step n={1} title="Prerequisites">
<ul className="space-y-2 mb-0">
{[
'Docker + Docker Compose (v2)',
'A Linux host reachable from the server by SSH',
'PostgreSQL (or use the bundled Compose profile)',
'Redis (or use the bundled Compose profile)',
].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" />
{item}
</li>
))}
</ul>
</Step>
<Step n={2} title="Quick start">
<p className="text-sm text-slate-400 mb-4">
Clone the repository, copy the example environment file, and deploy with a single command.
</p>
<CodeBlock code={`git clone https://gitea.example.com/netork/netork.git
cd netork
cp .env.example .env
# Edit .env — set DB_URL, REDIS_URL, SECRET_KEY
bash scripts/deploy.sh 192.168.1.10`} />
<p className="mt-4 text-sm text-slate-400">
The deploy script builds images, pushes them to the target host, and starts Docker Compose.
Alembic migrations run automatically on first start.
</p>
</Step>
<Step n={3} title="First run">
<p className="text-sm text-slate-400 mb-4">
Once the containers are up, navigate to your server IP in a browser.
</p>
<CodeBlock code={`http://192.168.1.10`} />
<ul className="mt-4 space-y-2">
{[
'Complete the setup wizard — it creates the admin user and writes the initial config',
'Log in with the admin credentials you just set',
'You land on the device inventory — currently empty',
].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" />
{item}
</li>
))}
</ul>
</Step>
<Step n={4} title="Adding your first device">
<ol className="space-y-3">
{[
'Click "Add device" and fill in hostname / IP, driver, and credentials',
'Click "Poll" to verify connectivity — you should see facts populate (vendor, OS, model)',
'Set a poll interval (e.g. 5 minutes) for continuous monitoring',
'Save — the device is now active and polled on the configured schedule',
].map((item, i) => (
<li key={i} className="flex items-start gap-3 text-sm text-slate-400">
<span className="shrink-0 text-xs font-mono text-sky-400 mt-0.5">{i + 1}.</span>
{item}
</li>
))}
</ol>
</Step>
<Step n={5} title="Next steps">
<div className="grid sm:grid-cols-3 gap-4">
{[
{ heading: 'NetBox sync', body: 'Connect netOrk to your NetBox instance to keep device records in sync automatically.' },
{ heading: 'Wazuh', body: 'Enable the Wazuh plugin to track agent enrollment and CVE counts per device.' },
{ heading: 'Scheduled reboots', body: 'Configure scheduled AP reboots for OpenWRT devices (GTK key rotation workaround).' },
].map((card) => (
<div key={card.heading} className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<h3 className="text-sm font-semibold text-slate-100 mb-2">{card.heading}</h3>
<p className="text-xs text-slate-400 leading-relaxed">{card.body}</p>
</div>
))}
</div>
</Step>
</div>
<div className="border-t border-slate-800 pt-8 flex gap-4">
<Link
to="/drivers"
className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium"
>
Supported drivers →
</Link>
<Link
to="/features"
className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium"
>
Full feature list →
</Link>
</div>
</div>
</div>
)
}
+450
View File
@@ -0,0 +1,450 @@
import { Link } from 'react-router-dom'
import {
MagnifyingGlassIcon,
ChartBarIcon,
WrenchScrewdriverIcon,
PuzzlePieceIcon,
} from '@heroicons/react/24/outline'
const DRIVERS = [
'OpenWRT', 'OPNsense', 'Proxmox VE', 'Linux', 'HP ProCurve / Aruba',
'TP-Link Jetstream', 'Netgear', 'Fritz!Box', 'Zyxel', 'OpenMediaVault',
'Sonos', 'Cisco IOS', 'Cisco IOS-XE', 'Cisco NX-OS', 'Arista EOS', 'Juniper JunOS',
]
function BrowserFrame({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-slate-700 overflow-hidden shadow-2xl">
<div className="flex items-center gap-1.5 border-b border-slate-700 bg-slate-800 px-4 py-2.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
<span className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
<span className="ml-4 text-xs text-slate-500 font-mono">{label}</span>
</div>
{children}
</div>
)
}
function StatusBadge({ status }: { status: 'active' | 'warning' | 'offline' }) {
const cls =
status === 'active' ? 'bg-green-500/20 text-green-400' :
status === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
return (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${cls}`}>
{status}
</span>
)
}
function MockDeviceList() {
const rows = [
{ name: 'fw-001.lan', ip: '10.0.0.1', driver: 'OPNsense', status: 'active' as const },
{ name: 'sw-core.lan', ip: '10.0.0.2', driver: 'HP ProCurve', status: 'warning' as const },
{ name: 'ap-001.lan', ip: '10.0.0.10', driver: 'OpenWRT', status: 'active' as const },
{ name: 'ap-002.lan', ip: '10.0.0.11', driver: 'OpenWRT', status: 'warning' as const },
{ name: 'pve-01.lan', ip: '10.0.0.20', driver: 'Proxmox VE', status: 'active' as const },
{ name: 'nas-01.lan', ip: '10.0.0.30', driver: 'OpenMediaVault', status: 'active' as const },
]
return (
<div className="bg-slate-950 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-100">Devices</span>
<span className="text-xs px-2.5 py-1 rounded-md bg-sky-600 text-white">+ Add device</span>
</div>
<div className="rounded-lg border border-slate-800 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-800 bg-slate-900">
<th className="px-3 py-2 text-left font-medium text-slate-500">Hostname</th>
<th className="px-3 py-2 text-left font-medium text-slate-500">IP</th>
<th className="px-3 py-2 text-left font-medium text-slate-500 hidden sm:table-cell">Driver</th>
<th className="px-3 py-2 text-left font-medium text-slate-500">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.name} className="border-b border-slate-800 last:border-0 hover:bg-slate-800/40">
<td className="px-3 py-2 font-mono text-sky-400">{r.name}</td>
<td className="px-3 py-2 font-mono text-slate-400">{r.ip}</td>
<td className="px-3 py-2 text-slate-400 hidden sm:table-cell">{r.driver}</td>
<td className="px-3 py-2"><StatusBadge status={r.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function MockDeviceDetail() {
return (
<div className="bg-slate-950 p-4">
<div className="rounded-lg border border-slate-800 bg-slate-900 p-4 mb-3">
<div className="flex items-start justify-between mb-3">
<div>
<p className="text-xs font-semibold text-slate-100">ap-001.lan</p>
<p className="text-xs text-slate-500 font-mono">10.0.0.10</p>
</div>
<StatusBadge status="active" />
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span className="text-slate-500">Vendor</span><p className="text-slate-300">GL.iNet</p></div>
<div><span className="text-slate-500">OS</span><p className="text-slate-300">OpenWRT 23.05</p></div>
<div><span className="text-slate-500">Driver</span><p className="text-slate-300">openwrt</p></div>
<div><span className="text-slate-500">Last poll</span><p className="text-slate-300">2 min ago</p></div>
</div>
</div>
<div className="flex gap-2 text-xs mb-3">
{['Interfaces', 'LLDP', 'ARP', 'VLANs', 'Services'].map((t) => (
<span key={t} className={`px-2 py-1 rounded text-xs ${t === 'Interfaces' ? 'bg-sky-600/20 text-sky-400' : 'text-slate-500 hover:text-slate-300'}`}>{t}</span>
))}
</div>
<div className="rounded-lg border border-slate-800 overflow-hidden">
<table className="w-full text-xs">
<thead><tr className="bg-slate-900 border-b border-slate-800">
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">Interface</th>
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">IP</th>
<th className="px-3 py-1.5 text-left text-slate-500 font-medium">State</th>
</tr></thead>
<tbody>
{[['br-lan', '10.0.0.10/24', 'up'], ['wlan0', '—', 'up'], ['eth0', '—', 'up']].map(([iface, ip, state]) => (
<tr key={iface} className="border-b border-slate-800 last:border-0">
<td className="px-3 py-1.5 font-mono text-slate-300">{iface}</td>
<td className="px-3 py-1.5 font-mono text-slate-400">{ip}</td>
<td className="px-3 py-1.5"><span className="text-green-400">{state}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function MockVlans() {
return (
<div className="bg-slate-950 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-semibold text-slate-100">VLANs</span>
<span className="text-xs px-2.5 py-1 rounded-md bg-sky-600 text-white">+ Add VLAN</span>
</div>
<div className="space-y-2">
{[
{ id: 10, name: 'management', devices: 6, drift: false },
{ id: 20, name: 'iot', devices: 4, drift: true },
{ id: 30, name: 'servers', devices: 3, drift: false },
{ id: 40, name: 'guest', devices: 2, drift: false },
].map((v) => (
<div key={v.id} className="flex items-center justify-between rounded-lg border border-slate-800 bg-slate-900 px-3 py-2">
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-slate-500 w-8">#{v.id}</span>
<span className="text-xs text-slate-300">{v.name}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">{v.devices} devices</span>
{v.drift
? <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400">drift</span>
: <span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">clean</span>
}
</div>
</div>
))}
</div>
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3">
<p className="text-xs text-yellow-400 font-medium mb-1">Drift detected — VLAN 20 (iot)</p>
<p className="text-xs text-slate-400">ap-002.lan: VLAN name mismatch. Expected "iot", got "IoT-devices".</p>
<button className="mt-2 text-xs px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-500 text-white transition-colors">Fix now</button>
</div>
</div>
)
}
function MockSecurity() {
return (
<div className="bg-slate-950 p-4">
<div className="mb-3">
<span className="text-xs font-semibold text-slate-100">Security — ap-001.lan</span>
</div>
<div className="space-y-2">
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">Wazuh Agent</span>
<StatusBadge status="active" />
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center rounded bg-red-500/10 border border-red-500/20 py-1.5">
<p className="text-red-400 font-semibold">2</p>
<p className="text-slate-500">Critical</p>
</div>
<div className="text-center rounded bg-yellow-500/10 border border-yellow-500/20 py-1.5">
<p className="text-yellow-400 font-semibold">7</p>
<p className="text-slate-500">High</p>
</div>
<div className="text-center rounded bg-blue-500/10 border border-blue-500/20 py-1.5">
<p className="text-blue-400 font-semibold">14</p>
<p className="text-slate-500">Medium</p>
</div>
</div>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">Graylog syslog</span>
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">forwarding</span>
</div>
<p className="text-xs text-slate-500 mt-1">10.0.0.50:514 (UDP)</p>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">CrowdSec</span>
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">active</span>
</div>
<p className="text-xs text-slate-500 mt-1">3 decisions · 0 bans (24 h)</p>
</div>
</div>
</div>
)
}
export default function 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">
Network orchestration<br />
<span className="text-sky-400">for heterogeneous infrastructure.</span>
</h1>
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mx-auto mb-10">
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.
</p>
<div className="flex flex-wrap gap-4 justify-center">
<Link
to="/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 →
</Link>
<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"
>
View features
</Link>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 mt-16">
<BrowserFrame label="netork.local / devices">
<MockDeviceList />
</BrowserFrame>
</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">
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.
</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">Discover everything on your network</h3>
<p className="text-sm text-slate-400 leading-relaxed">
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.
</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">Poll device state continuously</h3>
<p className="text-sm text-slate-400 leading-relaxed">
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.
</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">Detect drift. Fix it.</h3>
<p className="text-sm text-slate-400 leading-relaxed">
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.
</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">Works with your hardware</h2>
<p className="text-base text-slate-400 leading-relaxed max-w-xl mx-auto mb-10">
netOrk ships with custom NAPALM drivers for 11 device types, plus all built-in NAPALM drivers.
New drivers follow a documented registration pattern.
</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">
Full driver reference →
</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">
{/* Row 1 — left text, right screenshot */}
<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">Device detail at a glance</h2>
<p className="text-base text-slate-400 leading-relaxed">
Hostname, IP, vendor, OS version, last poll time, and active warnings on one card.
Tabbed detail view for interfaces, LLDP neighbors, ARP table, VLAN membership,
packages, services, and scheduled jobs.
</p>
</div>
<BrowserFrame label="netork.local / devices / ap-001">
<MockDeviceDetail />
</BrowserFrame>
</div>
{/* Row 2 — right text, left screenshot */}
<div className="grid md:grid-cols-2 gap-12 items-center">
<BrowserFrame label="netork.local / vlans">
<MockVlans />
</BrowserFrame>
<div>
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">Intent-based VLAN and SSID management</h2>
<p className="text-base text-slate-400 leading-relaxed">
Define VLAN names and SSID settings once. netOrk compares them against every polled
device and pushes corrections automatically via UCI (OpenWRT) or the device's native API.
</p>
</div>
</div>
{/* Row 3 — left text, right screenshot */}
<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">Security visibility per device</h2>
<p className="text-base text-slate-400 leading-relaxed">
Wazuh agent status, CVE counts by severity, and recent alerts — all linked to the device
record. One-click agent install if the agent is missing.
Graylog syslog forwarding status with auto-fix.
</p>
</div>
<BrowserFrame label="netork.local / devices / ap-001 / security">
<MockSecurity />
</BrowserFrame>
</div>
</div>
</section>
<div className="border-t border-slate-800" />
{/* 6 — Plugin System */}
<section className="py-24">
<div className="max-w-7xl mx-auto px-6">
<div className="rounded-xl border border-slate-800 bg-slate-900 p-8 md:p-12">
<div className="mb-4 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600/10">
<PuzzlePieceIcon className="h-5 w-5 text-sky-400" />
</div>
<h2 className="text-2xl md:text-3xl font-bold text-slate-100 mb-4">Built to extend</h2>
<p className="text-base text-slate-400 leading-relaxed max-w-2xl mb-6">
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.
</p>
<Link to="/plugins" className="text-sky-400 hover:text-sky-300 transition-colors text-sm font-medium">
Plugin system docs →
</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">Self-hosted. One command.</h2>
<p className="text-base text-slate-400 leading-relaxed">
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.
</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 Footer */}
<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-8">Start managing your network.</h2>
<Link
to="/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"
>
Read the docs →
</Link>
</div>
</section>
</>
)
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})