Author SHA1 Message Date
Tomáš Dvořák 1e4e39f001 Merge pull request #14 from Dvorinka/feat/external-monitoring
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
feat(infra): external monitoring stack (Beszel + Prometheus/Grafana)
2026-10-03 10:38:50 +02:00
Tomas Dvorak 4557af6c93 Merge remote-tracking branch 'origin/main' into feat/external-monitoring
# Conflicts:
#	CHANGELOG.md
2026-10-03 10:38:47 +02:00
Tomáš Dvořák a59d2eb558 Merge pull request #13 from Dvorinka/feat/casaos-compose
feat(infra): CasaOS compose with persistent /DATA bind
2026-10-03 10:38:29 +02:00
Tomas DvorakandDevin f2aa9ceba8 feat(infra): external monitoring stack (Beszel + Prometheus/Grafana)
Homelab monitoring design (docs/specs/2026-10-03-external-monitoring.md):
Beszel agents own the two physical hosts, prometheus-pve-exporter covers
every LXC/VM via the PVE API with no guest agents, and Grafana supplies
graphs. Dash needs zero code changes — JSON-path widgets already carry an
Authorization header and can walk Prometheus/Beszel JSON, embed widgets
iframe Grafana panels, and monitors + notification transports provide
the alerting layer for unmanaged hardware like the CM834 switch.

infra/monitoring/ ships compose.yaml, prometheus.yml, pve.yml.example
(gitignored real file), and Grafana datasource provisioning.
docs/monitoring.md documents deployment and widget recipes.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-03 10:36:27 +02:00
Tomas DvorakandDevin a94ae1889a feat(infra): add CasaOS compose with persistent /DATA bind
CasaOS copies imported compose files to /tmp/casaos-compose-app-* and
runs them from there, so the repo compose's relative ./data bind fails
at install ("bind source path does not exist") and would be wiped on
reboot. infra/compose.casaos.yaml mounts /DATA/AppData/dash instead and
carries x-casaos metadata for the app tile.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-03 10:21:18 +02:00
Tomáš Dvořák 3ffc01d710 Merge pull request #12 from Dvorinka/feat/landing-page
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
feat(landing): static marketing page for Vercel
2026-09-25 13:23:43 +02:00
Tomas DvorakandDevin d1486d5ec8 feat(landing): rebuild as Next.js app with live product mocks
Replaces the static HTML page with a Next.js 15 + Tailwind v4 + Motion
app. The hero renders the dashboard itself - live tiles, ticking clock,
wandering metrics, pulsing status dots - instead of screenshots. Adds a
playable four-renderer switcher (bento/cards/index/console) with layout
animations, live monitor/domain/system mocks, and a cycling alert feed.

Vercel project dash is now git-connected with root dir landing/; pushes
deploy automatically. Canonical alias: dash-homelab.vercel.app.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 13:19:06 +02:00
Tomas DvorakandDevin e88429514b feat(landing): static marketing page for Vercel
Single self-contained index.html under landing/ — no build step.
Product design tokens (dark theme, Geist + Geist Mono self-hosted,
#3b82f6 accent), hero with framed board screenshot, feature bento,
screenshots strip, install commands with copy buttons.

Deployed as Vercel project "dash" -> https://dash.vercel.app

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 12:57:14 +02:00
Tomáš Dvořák d18b4f6825 Merge pull request #11 from Dvorinka/ci/release-on-main
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
ci(release): auto-tag and release when ci passes on main
2026-09-23 11:35:31 +02:00
36 changed files with 3516 additions and 0 deletions

No files matched your search

+3
View File
@@ -15,6 +15,7 @@ data/*
# env / secrets
.env
.env.*
infra/monitoring/pve.yml
# misc
.DS_Store
@@ -27,3 +28,5 @@ dist/
# local tooling artifacts
.playwright-mcp/
.playwright-cli/
.vercel
.env*
+2
View File
@@ -9,6 +9,8 @@ Optional auth (off by default), no YAML. See `ROADMAP.md` (canonical) and
- `apps/frontend` — React 18 + Vite + TS strict + Tailwind v4 + shadcn/ui
- `apps/backend` — Go + Gin + zap, CGO-free, SQLite via `modernc.org/sqlite`
- `packages/api-client` — generated TS client; `openapi.yaml` at repo root is the contract
- `landing/` — Next.js 15 marketing site (own package.json, not a workspace);
Vercel project `dash`, root dir `landing`, git-connected to this repo
- `infra/` — Dockerfile (multi-stage, one image)
- `tools/oxlint/anti-slop` — vendored lint plugin (do not extend casually)
- `data/` — runtime SQLite + uploaded icons (gitignored)
+12
View File
@@ -6,6 +6,18 @@ All notable changes to Dash. Format follows
## [Unreleased]
### Added
- External monitoring stack `infra/monitoring/` (Prometheus + Grafana +
prometheus-pve-exporter + Beszel hub) with a `docs/monitoring.md` guide:
Beszel agents cover the physical hosts, pve-exporter covers all LXCs/VMs
via the PVE API, and existing JSON-path/embed widgets plus monitors
consume the data — no app code required
- CasaOS deployment file `infra/compose.casaos.yaml` with app-store
(`x-casaos`) metadata and a persistent `/DATA/AppData/dash` bind mount;
the repo compose's relative `./data` fails under CasaOS, which runs
compose from `/tmp` and loses it on reboot
## [2.1.0] — 2026-09-23
### Added
+1
View File
@@ -117,6 +117,7 @@ in [docs/installation.md](docs/installation.md#dash-agent-system-monitoring).
- [docs/installation.md](docs/installation.md) — Docker, binary, agent, reverse proxy, upgrades
- [docs/configuration.md](docs/configuration.md) — env vars, notifications, auth, boards, operations
- [docs/monitoring.md](docs/monitoring.md) — external monitoring stack (Beszel + Prometheus/Grafana) and how Dash consumes it
- [docs/widgets.md](docs/widgets.md) — widget development guide
- `openapi.yaml` — API contract (generated TS client in `packages/api-client`)
- [ROADMAP.md](ROADMAP.md) — design decisions and release history
+2
View File
@@ -9,5 +9,7 @@ services:
ports:
- "3000:3000"
volumes:
# CasaOS: use infra/compose.casaos.yaml — CasaOS runs compose from /tmp,
# so this relative path fails there and would not survive a reboot.
- ./data:/data
restart: unless-stopped
+13
View File
@@ -43,6 +43,19 @@ To build the image locally instead of pulling:
docker compose up --build
```
## CasaOS
Do not paste `docker-compose.yml` into CasaOS's custom install. CasaOS copies
imported compose files to `/tmp/casaos-compose-app-*` and runs them there, so
the relative `./data` bind resolves under `/tmp` — install fails with
`bind source path does not exist`, and anything written would be wiped on
reboot anyway.
Use `infra/compose.casaos.yaml` instead (Apps → Custom Install → Import). It
mounts `/DATA/AppData/dash` on the host — persistent across reboots and
browsable in the CasaOS Files app — and carries the `x-casaos` metadata the
app tile uses.
## Binary
Release archives contain a static `dash` binary (no CGO, no dependencies).
+119
View File
@@ -0,0 +1,119 @@
# Monitoring your infrastructure
Dash displays and alerts; it does not collect metrics beyond what
`dash-agent` pushes. For real infrastructure monitoring, run the reference
stack in [`infra/monitoring/`](../infra/monitoring/) next to Dash — it works
on any host. A small Debian LXC on the Proxmox box is a good home.
## What watches what
| Layer | Tool | Covers |
|---|---|---|
| Physical hosts | Beszel hub + agents | Proxmox host, Windows 11 box — CPU/mem/disk/net/temps |
| Cluster + apps | Prometheus + pve-exporter | Every LXC/VM via the PVE API (no guest agents), Dash's own `/api/metrics` |
| Dashboards | Grafana | Graphs over Prometheus, embedded into Dash via iframes |
| Up/down + alerts | Dash monitors | Router, hosts, services — with notification transports and incidents |
Two agents total — Beszel deliberately owns only the physical machines; the
PVE API already knows everything about the guests.
## 1. Deploy the stack
```sh
cd infra/monitoring
cp pve.yml.example pve.yml # fill in the PVE API token (see below)
# edit prometheus.yml: replace PVE_HOST and DASH_HOST with real addresses
docker compose up -d
```
UIs: Prometheus `:9090`, Grafana `:3001`, Beszel `:8090`, pve-exporter `:9221`.
### Proxmox API token (for pve-exporter)
On the PVE host:
```sh
pveum user add dashmon@pve --comment "read-only monitoring"
pveum aclmod / -user dashmon@pve -role PVEAuditor
```
Then **Datacenter → Permissions → API Tokens → Add** a token named `dash`
for `dashmon@pve`, uncheck *Privilege Separation*, copy the secret into
`pve.yml`. Token auth means no password in the file.
### Beszel agents (physical hosts)
1. Open Beszel `http://<hub>:8090`, create the admin account.
2. **Add System** → the hub generates the exact install command with the
key embedded — binary/systemd for the PVE host, Windows build for the
dev box (run it under NSSM or Task Scheduler for boot persistence).
3. Agents listen on `:45876`; the hub connects to them. On the same LAN
this needs no extra plumbing.
## 2. Show it in Dash — no code required
### Live values → JSON value widget
Add item → widget type **JSON value**:
**Beszel** — fields are version-dependent; browse the API to pick a path:
- URL: `http://<beszel>:8090/api/collections/systems/records`
- Header: `Authorization: Bearer <token>` — mint a superuser token/key in
the PocketBase admin at `http://<beszel>:8090/_/`
- Path: e.g. `items[0].info.cpu` (verify in the JSON response)
**Prometheus** — instant queries return JSON:
- URL: `http://<prometheus>:9090/api/v1/query?query=pve_cpu_usage_ratio`
- Path: `data.result[0].value[1]`
- Useful queries: `pve_cpu_usage_ratio`, `pve_memory_usage_ratio`,
`pve_disk_usage_ratio`, `pve_up`
### Graphs → Embed widget
Grafana dashboard → share a panel → **Embed** gives a `d-solo` URL. Paste it
into a Dash **Embed** widget (append `&kiosk` for chrome-free panels). The
shipped compose enables anonymous Viewer + embedding, so iframes render
without login on the LAN — remove the `GF_AUTH_ANONYMOUS_*` and
`GF_SECURITY_ALLOW_EMBEDDING` env lines if you'd rather require sign-in, and
use Grafana *public dashboard* links instead.
The whole Beszel UI also iframes cleanly if you want its charts in a tile.
## 3. Alert on outages → Dash monitors
The point of the exercise: when the unmanaged switch hangs, everything
*behind* it drops at once while router-side checks stay green. That
co-failure pattern — timestamped, pushed to your phone — is as close to
"the switch died" as an unmanaged box allows.
Recommended monitors:
| Target | Type | Why |
|---|---|---|
| Router / gateway IP | Ping | Upstream reference point |
| Proxmox host | Ping + HTTP `:8006` | Behind the switch |
| Each LXC's service | HTTP/TCP | Per-app liveness |
| Win11 box | TCP `:3389` (RDP) | Behind the switch |
| Prometheus/Grafana/Beszel | HTTP | Watch the watchers |
Settings → Notifications: configure ntfy/Telegram/etc. once and every
down-transition alerts you with an incident record.
**Possible switch fix, unproven:** cheap-switch hangs are often EEE
(energy-efficient ethernet) negotiation bugs. `ethtool --set-eee <nic> eee
off` on the host NICs is a reasonable mitigation. Failing that, the usual
suspects are heat and the power brick.
## 4. Files
```
infra/monitoring/compose.yaml stack definition
infra/monitoring/prometheus.yml scrape config (edit hostnames)
infra/monitoring/pve.yml.example PVE token template → copy to pve.yml
infra/monitoring/grafana/datasources.yaml Prometheus source, auto-provisioned
```
`pve.yml` holds a credential and is gitignored. Stack design rationale:
[`docs/specs/2026-10-03-external-monitoring.md`](specs/2026-10-03-external-monitoring.md).
@@ -0,0 +1,81 @@
# External Monitoring Stack — Design
Date: 2026-10-03
Status: approved in chat 2026-10-03 — implementing
## 1. Goal
Monitor a homelab (one Proxmox host with LXCs, one always-on Windows 11 dev
box, an unmanaged Ugreen CM834 switch, a router) with the collection layer
living **outside** Dash. Dash consumes data for display and owns up/down
alerting. Trigger: an unmanaged switch hung overnight with no way to know
when or what failed.
## 2. Lane split — why two engines
Two engines were requested. They earn their place only if lanes don't
overlap:
| Tool | Lane | Scope |
|---|---|---|
| Beszel (hub + agents) | The two **physical** machines: PVE host, Win11 | Agent push, Windows support, instant host tiles, standalone alerts |
| Prometheus + `prometheus-pve-exporter` | Everything **scrapable**: all LXCs/VMs via the PVE API (no guest agents), Dash's own `/api/metrics` | System of record, PromQL |
| Grafana | Graphing front for Prometheus | Panel iframes for Dash |
Deliberately absent: `node_exporter`, `windows_exporter` — Beszel already
owns the physical hosts. Two agents total, not two per machine.
## 3. Dash integration — zero code changes
Existing primitives cover the whole display layer:
- **JSON-path widget** → Beszel PocketBase API and Prometheus
`/api/v1/query` (`data.result[0].value[1]` shape already walkable;
`header` config field carries `Authorization: Bearer …`)
- **Embed widget** → Grafana panel URLs (anonymous Viewer + embedding
enabled on the LAN) and the Beszel UI
- **Monitors** → ping/TCP/HTTP checks + notification transports +
auto-incidents = the "something died at 03:12" alarm the user lacked
- **Service links** → per-console deep links
## 4. The switch problem
The Ugreen CM834 is unmanaged: no IP, no SNMP, no telemetry. Root cause is
unobservable. What monitoring delivers instead:
- Monitors on router IP, PVE host, and one service per LXC — all reached
*through* the switch. When it hangs, behind-switch targets fail together
while router-side checks stay green: a timestamped "it was the switch"
signature plus an alert to ntfy/Telegram.
- Plausible mitigation (not proven): disable EEE on host NICs —
`ethtool --set-eee <nic> eee off`. Cheap-switch EEE hangs are a known
failure mode.
## 5. Deliverables
```
infra/monitoring/
compose.yaml # prometheus + grafana + pve-exporter + beszel-hub
prometheus.yml # scrape config: pve (via exporter) + dash /api/metrics
pve.yml.example # PVE API token config (copy → pve.yml, gitignored)
grafana/datasources.yaml # auto-provisions the Prometheus source
docs/monitoring.md # deploy guide + Dash widget/monitor recipes
```
No application code. No schema changes. Stack runs anywhere — an LXC on the
Proxmox host is the suggested home.
## 6. Security notes
- Grafana anonymous **Viewer** + `allow_embedding` are set for LAN iframe
use; both are called out in the guide and removable.
- `pve.yml` carries an API token — `pve.yml.example` is committed, the real
file is documented as gitignored-adjacent (lives next to the compose,
outside the repo if preferred).
- Widget secrets already live in item config JSON per the original spec.
## 7. Verification
- `docker compose -f infra/monitoring/compose.yaml config` valid
- `promtool check config prometheus.yml` (dockerized promtool) valid
- Docs-only otherwise; repo gate (typecheck/lint/build/go tests) unchanged
+57
View File
@@ -0,0 +1,57 @@
# CasaOS-ready compose. Import via Apps -> Custom Install -> Import from file.
#
# Do NOT paste the repo's docker-compose.yml into CasaOS: it copies imported
# files to /tmp/casaos-compose-app-* and runs them there, so a relative
# "./data" bind resolves under /tmp — install fails with "bind source path
# does not exist", and even if created the data dies on reboot. This file
# binds /DATA/AppData/dash instead — CasaOS's persistent app-data root,
# browsable in the Files app.
name: dash
services:
dash:
image: ghcr.io/dvorinka/dash:latest
container_name: dash
restart: unless-stopped
ports:
- "3000:3000"
environment:
- TZ=${TZ:-UTC}
volumes:
# Short-form bind auto-creates the host dir; long-form "type: bind" does
# not unless bind.create_host_path is set.
- /DATA/AppData/dash:/data
x-casaos:
envs:
- container: TZ
description:
en_us: "Timezone, e.g. Europe/Prague"
ports:
- container: "3000"
description:
en_us: "Web UI and API"
volumes:
- container: /data
description:
en_us: "All Dash state: SQLite database and uploaded icons"
x-casaos:
architectures:
- amd64
- arm64
main: dash
author: Dvorinka
category: Dashboard
developer: Dvorinka
description:
en_us: "Self-hosted homelab dashboard: services, uptime monitors, domain watch, system metrics. Single container, all state in /data."
icon: https://raw.githubusercontent.com/Dvorinka/Dash/main/dash.svg
tagline:
en_us: "Homelab dashboard in a single container"
thumbnail: ""
title:
en_us: Dash
scheme: http
port_map: "3000"
index: /
store_app_id: dash
+65
View File
@@ -0,0 +1,65 @@
# External monitoring stack — independent of the Dash container.
# Run on any host (a small Debian LXC on Proxmox works well):
# cp pve.yml.example pve.yml # fill in your PVE API token
# docker compose up -d
# All state lives in named volumes.
name: dash-monitoring
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.retention.time=30d
- --web.enable-lifecycle # POST /-/reload applies config changes
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
environment:
# LAN convenience for Dash iframe widgets: read-only anonymous access.
# Anyone on your network can VIEW dashboards without logging in.
# Delete these three lines to require sign-in instead.
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
GF_SECURITY_ALLOW_EMBEDDING: "true" # required for iframe embeds
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./grafana/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro
- grafana-data:/var/lib/grafana
ports:
- "3001:3000"
depends_on:
- prometheus
pve-exporter:
image: prompve/prometheus-pve-exporter:latest
container_name: pve-exporter
restart: unless-stopped
command: ["--config.file", "/etc/pve.yml"]
volumes:
- ./pve.yml:/etc/pve.yml:ro
ports:
- "9221:9221"
beszel-hub:
image: henrygd/beszel:latest
container_name: beszel
restart: unless-stopped
volumes:
- beszel-data:/beszel_data
ports:
- "8090:8090"
volumes:
prometheus-data:
grafana-data:
beszel-data:
+10
View File
@@ -0,0 +1,10 @@
# Auto-provisions the Prometheus datasource so Grafana works on first boot.
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
+31
View File
@@ -0,0 +1,31 @@
# Prometheus scrape config for the Dash-adjacent monitoring stack.
# Adjust the host addresses to your LAN before starting.
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
# Proxmox cluster: node + every LXC/VM via the PVE API.
# No agents inside guests — pve-exporter asks the API on :8006.
- job_name: pve
metrics_path: /pve
params:
module: [default]
cluster: ["1"]
node: ["1"]
static_configs:
- targets: ["PVE_HOST:8006"] # <- your Proxmox host
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: pve-exporter:9221
# Dash itself — it exposes Prometheus-format metrics already.
- job_name: dash
metrics_path: /api/metrics
static_configs:
- targets: ["DASH_HOST:3000"] # <- wherever Dash runs (CasaOS box, etc.)
+13
View File
@@ -0,0 +1,13 @@
# prometheus-pve-exporter config — copy to pve.yml and fill in.
# pve.yml contains a credential: keep it out of git.
#
# Recommended setup on the Proxmox host:
# pveum user add dashmon@pve --comment "read-only monitoring"
# pveum aclmod / -user dashmon@pve -role PVEAuditor
# Datacenter -> Permissions -> API Tokens -> add token "dash" for dashmon@pve,
# uncheck "Privilege Separation" (the token inherits the user's PVEAuditor role)
default:
user: dashmon@pve
token_name: dash
token_value: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
verify_ssl: false # PVE ships self-signed certs on LAN
+4
View File
@@ -0,0 +1,4 @@
.vercel
.env*
node_modules/
.next/
Binary file not shown.
Binary file not shown.
+67
View File
@@ -0,0 +1,67 @@
@import "tailwindcss";
/* Product tokens — same palette as the app (apps/frontend/src/index.css). */
@theme {
--color-bg: #0a0a0a;
--color-surface: #111111;
--color-surface-hover: #161616;
--color-line: rgba(255, 255, 255, 0.08);
--color-line-strong: rgba(255, 255, 255, 0.14);
--color-text: #fafafa;
--color-dim: #a1a1aa;
--color-faint: #52525b;
--color-accent: #3b82f6;
--color-up: #22c55e;
--color-down: #f59e0b;
--color-destructive: #ef4444;
--color-iconbg: #1c1c1e;
--ease-snap: cubic-bezier(0.2, 0, 0, 1);
--font-sans: var(--font-geist), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
--animate-pulse-dot: pulse-dot 2.4s ease-in-out infinite;
--animate-float: float 7s ease-in-out infinite;
--animate-scan: scan 5s linear infinite;
--animate-draw: draw 2.2s var(--ease-snap) forwards;
@keyframes pulse-dot {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); }
50% { opacity: 0.75; box-shadow: 0 0 0 4px rgba(34, 197, 94, 0); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes scan {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
@keyframes draw {
to { stroke-dashoffset: 0; }
}
}
html {
-webkit-font-smoothing: antialiased;
scroll-behavior: smooth;
}
body {
background: var(--color-bg);
color: var(--color-text);
min-height: 100dvh;
}
::selection {
background: rgba(59, 130, 246, 0.3);
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation: none !important;
transition-duration: 0.01ms !important;
}
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+45
View File
@@ -0,0 +1,45 @@
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import "./globals.css";
const geist = localFont({
src: "./fonts/geist-var.woff2",
variable: "--font-geist",
weight: "100 900",
display: "swap",
});
const geistMono = localFont({
src: "./fonts/geist-mono-var.woff2",
variable: "--font-geist-mono",
weight: "100 900",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL("https://dash-homelab.vercel.app"),
title: "Dash - Self-hosted homelab dashboard",
description:
"Service boards, uptime monitors, domain watch, and system metrics in one container. A single static Go binary with an embedded React UI. No YAML, no external database.",
openGraph: {
title: "Dash - Self-hosted homelab dashboard",
description:
"Service boards, uptime monitors, domain watch, and system metrics in one container. One static Go binary, no YAML.",
type: "website",
images: ["/dash.svg"],
},
};
export const viewport: Viewport = {
themeColor: "#0a0a0a",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
+270
View File
@@ -0,0 +1,270 @@
import { Nav } from "../components/Nav";
import { Board } from "../components/Board";
import { RendererDemo } from "../components/RendererDemo";
import { MonitorMock, DomainMock, SystemMock, StatusBand } from "../components/Mocks";
import { Install } from "../components/Install";
import { CopyButton } from "../components/CopyButton";
import { Reveal } from "../components/Reveal";
function SectionHead({ label, tag }: { label: string; tag: string }) {
return (
<div className="mb-7 flex items-center gap-3">
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-dim">
{label}
</h2>
<span className="h-px flex-1 bg-line" />
<span className="font-mono text-[11px] text-faint">{tag}</span>
</div>
);
}
function Feature({
flip = false,
title,
children,
mock,
}: {
flip?: boolean;
title: string;
children: React.ReactNode;
mock: React.ReactNode;
}) {
return (
<Reveal>
<div className="grid items-center gap-8 md:grid-cols-[5fr_7fr]">
<div className={flip ? "md:order-2" : ""}>
<h3 className="text-[22px] font-medium tracking-tight">{title}</h3>
<p className="mt-2.5 max-w-[46ch] text-[14.5px] leading-relaxed text-dim">{children}</p>
</div>
<div className={flip ? "md:order-1" : ""}>{mock}</div>
</div>
</Reveal>
);
}
export default function Page() {
return (
<>
<Nav />
{/* ---------- hero ---------- */}
<header className="mx-auto max-w-5xl px-5 pt-20 sm:pt-24">
<Reveal>
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 font-mono text-[11.5px] text-dim">
<span className="h-1.5 w-1.5 rounded-full bg-up animate-pulse-dot" />
self-hosted · open source · MIT
</span>
</Reveal>
<Reveal delay={0.06}>
<h1 className="mt-6 max-w-[16ch] text-[42px] font-medium leading-[1.04] tracking-[-0.035em] sm:text-[64px]">
Your homelab, on one calm screen.
</h1>
</Reveal>
<Reveal delay={0.12}>
<p className="mt-5 max-w-[58ch] text-[16.5px] leading-relaxed text-dim">
Service boards, uptime monitors, domain watch, and system metrics - a
single static Go binary with the UI baked in. No YAML, no external
database, no mandatory accounts.
</p>
</Reveal>
<Reveal delay={0.18} className="mt-8 flex flex-wrap items-center gap-2.5">
<a
href="#install"
className="flex h-10 items-center rounded-lg bg-text px-4 text-[14px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
>
Get started
</a>
<a
href="https://github.com/Dvorinka/Dash"
className="flex h-10 items-center rounded-lg border border-line bg-surface px-4 text-[14px] font-medium text-dim transition-colors hover:border-line-strong hover:text-text"
>
GitHub
</a>
<span className="flex h-10 items-center gap-1 rounded-lg border border-line bg-surface pl-3.5 pr-1.5 font-mono text-[12.5px]">
<span className="text-faint">$</span>
<code>docker run -p 3000:3000 ghcr.io/dvorinka/dash</code>
<CopyButton
text="docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest"
label="Copy docker command"
/>
</span>
</Reveal>
{/* the product, rendered live */}
<Reveal delay={0.24} className="relative mt-14">
<div
aria-hidden="true"
className="pointer-events-none absolute -inset-x-8 -top-10 h-40 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(59,130,246,0.10),transparent)]"
/>
<Board />
</Reveal>
<Reveal className="mt-14 grid grid-cols-2 overflow-hidden rounded-xl border border-line sm:grid-cols-4">
{[
["Deploy", "one static binary"],
["Database", "SQLite, embedded"],
["Config", "UI-managed, no YAML"],
["License", "MIT"],
].map(([k, v], i) => (
<div key={k} className={`px-5 py-4 ${i > 0 ? "border-l border-line" : ""} ${i >= 2 ? "max-sm:border-t max-sm:border-line" : ""} ${i === 2 ? "max-sm:border-l-0" : ""}`}>
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-faint">{k}</div>
<div className="mt-1.5 text-[14.5px] font-medium">{v}</div>
</div>
))}
</Reveal>
</header>
<main className="mx-auto max-w-5xl px-5">
{/* ---------- renderers ---------- */}
<section id="renderers" className="scroll-mt-24 pt-24">
<SectionHead label="Boards" tag="the differentiator" />
<div className="grid items-center gap-8 md:grid-cols-[4fr_8fr]">
<Reveal>
<h3 className="text-[22px] font-medium tracking-tight">
One board state. Four skins.
</h3>
<p className="mt-2.5 max-w-[42ch] text-[14.5px] leading-relaxed text-dim">
Bento, Cards, Index, and Console renderers share the same drag-drop,
popovers, and state. Switching the look is a setting, not a migration.
Try it - this demo is live.
</p>
</Reveal>
<Reveal delay={0.1}>
<RendererDemo />
</Reveal>
</div>
</section>
{/* ---------- monitors ---------- */}
<section id="monitors" className="scroll-mt-24 pt-24">
<SectionHead label="Monitors" tag="checks on a schedule" />
<Feature title="Uptime that watches itself" mock={<MonitorMock />}>
HTTP, TCP, ping, DNS, keyword, JSON-path, and push checks on an
in-process scheduler. Heartbeat history, latency graphs, and alert
rules per monitor - down means you hear about it, not discover it.
</Feature>
</section>
{/* ---------- domains ---------- */}
<section className="pt-24">
<SectionHead label="Domains" tag="rdap + whois + tls" />
<Feature
flip
title="Every domain, accounted for"
mock={<DomainMock />}
>
Expiry countdowns, TLS certificate chains, DNS records attributed to
their provider, and CT-log subdomain discovery. Field-level history on
every change.
</Feature>
</section>
{/* ---------- systems ---------- */}
<section className="pt-24">
<SectionHead label="Systems" tag="push-based agent" />
<Feature title="Hosts that report in" mock={<SystemMock />}>
A tiny <code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">dash-agent</code>{" "}
pushes CPU, memory, disk, network, temperatures, and per-container
stats - SMART, ZFS, and GPU when the tooling exists. One binary per
host, nothing else.
</Feature>
</section>
{/* ---------- status + alerts ---------- */}
<section className="pt-24">
<SectionHead label="Status & alerts" tag="public pages, real transports" />
<Feature
flip
title="Tell users before they ask"
mock={<StatusBand />}
>
Automatic incidents on monitor down, maintenance windows, public
status pages, and SVG badges. Alerts dispatch through webhook, Slack,
Discord, Telegram, Gotify, ntfy, or SMTP.
</Feature>
</section>
{/* ---------- stack ---------- */}
<section id="stack" className="scroll-mt-24 pt-24">
<SectionHead label="Stack" tag="boring, on purpose" />
<Reveal>
<div className="flex flex-wrap gap-2">
{[
"go 1.24",
"gin",
"sqlite (modernc)",
"embed.FS",
"openapi contract",
"react 18",
"docker · ~34 MB",
"goreleaser binaries",
"prometheus /api/metrics",
"pwa",
].map((t) => (
<span
key={t}
className="rounded-md border border-line bg-surface px-3 py-1.5 font-mono text-[12px] text-dim"
>
{t}
</span>
))}
</div>
</Reveal>
<Reveal delay={0.08}>
<p className="mt-5 max-w-[62ch] text-[14.5px] leading-relaxed text-dim">
It needs a persistent scheduler, WHOIS on port 43, ICMP, and SMTP
egress - none of which exist on serverless. So the deploy story is{" "}
<code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">docker run</code>
, and this page is the only part of Dash hosted here.
</p>
</Reveal>
</section>
{/* ---------- install ---------- */}
<section id="install" className="scroll-mt-24 pt-24">
<SectionHead label="Install" tag="~1 min" />
<Install />
<Reveal delay={0.1}>
<p className="mt-5 max-w-[62ch] text-[14.5px] leading-relaxed text-dim">
Everything is configured from the UI at{" "}
<code className="rounded border border-line bg-surface px-1.5 py-0.5 font-mono text-[12.5px]">localhost:3000</code>
. All state lives in one directory. Importers for Homepage, Homarr,
and Dashy configs are built in.
</p>
</Reveal>
</section>
{/* ---------- bottom cta ---------- */}
<Reveal className="mt-24">
<div className="flex flex-wrap items-center justify-between gap-6 rounded-2xl border border-line bg-gradient-to-b from-surface to-bg p-10">
<div>
<h2 className="text-[26px] font-medium tracking-tight">Run it in one command.</h2>
<p className="mt-2 text-[14.5px] text-dim">MIT licensed. Self-hosted. Yours.</p>
</div>
<a
href="https://github.com/Dvorinka/Dash"
className="flex h-11 items-center rounded-lg bg-text px-5 text-[14px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
>
Star on GitHub
</a>
</div>
</Reveal>
</main>
<footer className="mt-24 border-t border-line">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-5 px-5 py-8 text-[13px] text-faint">
<span className="flex items-center gap-2">
<img src="/dash.svg" alt="" width={18} height={18} className="rounded-[4px]" />
Dash
</span>
<span>© 2026 · MIT License</span>
<div className="ml-auto flex gap-5">
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash">GitHub</a>
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash/tree/main/docs">Docs</a>
<a className="text-dim transition-colors hover:text-text" href="https://github.com/Dvorinka/Dash/releases">Releases</a>
</div>
</div>
</footer>
</>
);
}
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "motion/react";
/* A live miniature of the product - rendered, not screenshotted. */
type Service = {
name: string;
host: string;
letter: string;
tint: string;
};
const MEDIA: Service[] = [
{ name: "Plex", host: "plex.localhost:32400", letter: "P", tint: "#e5a00d" },
{ name: "Jellyfin", host: "jellyfin.localhost:8096", letter: "J", tint: "#aa5cc3" },
{ name: "Immich", host: "immich.localhost:2283", letter: "I", tint: "#f16d9a" },
];
const INFRA: Service[] = [
{ name: "Proxmox", host: "proxmox.localhost:8006", letter: "P", tint: "#e57000" },
{ name: "Pi-hole", host: "pihole.localhost", letter: "P", tint: "#f05454" },
{ name: "Gitea", host: "gitea.localhost:3000", letter: "G", tint: "#609926" },
];
function useReduced() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const fn = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}, []);
return reduced;
}
function useClock() {
const [now, setNow] = useState<Date | null>(null);
const reduced = useReduced();
useEffect(() => {
setNow(new Date());
if (reduced) return;
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, [reduced]);
return now;
}
function useWander(base: number, spread: number, ms: number) {
const [v, setV] = useState(base);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(
() => setV(Math.round(base + (Math.random() - 0.35) * spread)),
ms,
);
return () => clearInterval(t);
}, [base, spread, ms, reduced]);
return v;
}
function StatusDot({ down = false }: { down?: boolean }) {
return (
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${down ? "bg-down" : "bg-up animate-pulse-dot"}`}
/>
);
}
function Tile({
i,
className = "",
children,
}: {
i: number;
className?: string;
children: React.ReactNode;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: 0.05 * i, ease: [0.2, 0, 0, 1] }}
className={`rounded-lg border border-line bg-surface p-3 transition-colors duration-150 hover:border-line-strong hover:bg-surface-hover ${className}`}
>
{children}
</motion.div>
);
}
function ServiceTile({ s, i, down }: { s: Service; i: number; down?: boolean }) {
return (
<Tile i={i} className="col-span-3 flex items-center gap-3 sm:col-span-2">
<span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-iconbg text-sm font-semibold"
style={{ color: s.tint }}
>
{s.letter}
</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium">{s.name}</span>
<span className="block truncate font-mono text-[10.5px] text-faint">{s.host}</span>
</span>
<span className="ml-auto flex items-center gap-1.5 rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-dim">
<StatusDot down={down} />
{down ? "degraded" : "up"}
</span>
</Tile>
);
}
function ClockTile({ i }: { i: number }) {
const now = useClock();
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">local time</div>
<div className="mt-1.5 font-mono text-xl font-medium tabular-nums">
{now ? now.toLocaleTimeString("en-GB") : "--:--:--"}
</div>
<div className="mt-1 font-mono text-[10.5px] text-dim">Europe/Prague</div>
</Tile>
);
}
function PiholeTile({ i }: { i: number }) {
const blocked = useWander(24, 6, 2600);
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">pi-hole</div>
<div className="mt-1.5 text-xl font-medium tabular-nums">
{blocked.toFixed(1)}
<span className="text-sm text-dim">%</span>
</div>
<div className="mt-1 font-mono text-[10.5px] text-dim">queries blocked</div>
</Tile>
);
}
function HeartbeatTile({ i }: { i: number }) {
const [bars, setBars] = useState<number[]>(() => Array.from({ length: 26 }, () => 1));
const ping = useWander(42, 30, 1500);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(() => {
setBars((b) => [...b.slice(1), Math.random() > 0.06 ? 1 : 0]);
}, 1100);
return () => clearInterval(t);
}, [reduced]);
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="flex items-baseline justify-between">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">api</span>
<span className="font-mono text-[10.5px] tabular-nums text-dim">{ping} ms</span>
</div>
<div className="mt-2 flex h-7 items-end gap-[3px]">
{bars.map((v, k) => (
<span
key={k}
className={`flex-1 rounded-[2px] ${v ? "bg-up" : "bg-down"} transition-opacity duration-300`}
style={{ height: `${v ? 60 + ((k * 37) % 40) : 100}%`, opacity: v ? 0.85 : 1 }}
/>
))}
</div>
</Tile>
);
}
function SystemTile({ i }: { i: number }) {
const cpu = useWander(34, 26, 2200);
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">nas-01</div>
<div className="mt-1.5 flex items-baseline gap-1">
<span className="text-xl font-medium tabular-nums">{cpu}%</span>
<span className="font-mono text-[10.5px] text-dim">cpu</span>
</div>
<div className="mt-2 space-y-1">
<div className="h-1 w-full overflow-hidden rounded bg-iconbg">
<div className="h-full rounded bg-accent transition-all duration-700" style={{ width: `${Math.min(cpu, 100)}%` }} />
</div>
<div className="h-1 w-full overflow-hidden rounded bg-iconbg">
<div className="h-full rounded bg-up/70 transition-all duration-700" style={{ width: "61%" }} />
</div>
</div>
</Tile>
);
}
function DomainTile({ i }: { i: number }) {
return (
<Tile i={i} className="col-span-3 sm:col-span-2">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">tdvorak.dev</div>
<div className="mt-1.5 text-xl font-medium">
260<span className="text-sm text-dim">d</span>
</div>
<div className="mt-1 font-mono text-[10.5px] text-dim">until expiry · tls 81d</div>
</Tile>
);
}
export function Board() {
const [warn, setWarn] = useState(-1);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(() => {
const pick = Math.floor(Math.random() * MEDIA.length);
setWarn(pick);
setTimeout(() => setWarn(-1), 2600);
}, 11000);
return () => clearInterval(t);
}, [reduced]);
return (
<div className="overflow-hidden rounded-xl border border-line-strong bg-bg shadow-[0_24px_80px_-24px_rgba(0,0,0,0.7)]">
{/* window chrome */}
<div className="flex h-9 items-center gap-2 border-b border-line bg-surface px-3.5">
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-line-strong" />
<span className="ml-2 rounded-md border border-line bg-bg px-2 py-0.5 font-mono text-[10px] text-faint">
localhost:3000
</span>
<span className="ml-auto hidden items-center gap-1 rounded-md border border-line px-2 py-0.5 font-mono text-[10px] text-faint sm:flex">
⌘K
</span>
</div>
<div className="p-3.5 sm:p-5">
<div className="mb-2.5 flex items-center gap-3">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-dim">
Media
</span>
<span className="font-mono text-[10px] text-faint">05</span>
<span className="h-px flex-1 bg-line" />
</div>
<div className="grid grid-cols-6 gap-2.5">
{MEDIA.map((s, k) => (
<ServiceTile key={s.name} s={s} i={k} down={warn === k} />
))}
<ClockTile i={3} />
<PiholeTile i={4} />
</div>
<div className="mb-2.5 mt-5 flex items-center gap-3">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.14em] text-dim">
Infrastructure
</span>
<span className="font-mono text-[10px] text-faint">06</span>
<span className="h-px flex-1 bg-line" />
</div>
<div className="grid grid-cols-6 gap-2.5">
{INFRA.map((s, k) => (
<ServiceTile key={s.name} s={s} i={k + 5} />
))}
<HeartbeatTile i={8} />
<SystemTile i={9} />
<DomainTile i={10} />
</div>
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
export function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
aria-label={label}
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1400);
} catch {
/* clipboard unavailable - select manually */
}
}}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-faint transition-colors hover:bg-surface-hover hover:text-text"
>
{copied ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-up)" strokeWidth="2.5" aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { CopyButton } from "./CopyButton";
import { Reveal } from "./Reveal";
const CURL = "curl -fsSL https://raw.githubusercontent.com/Dvorinka/Dash/main/install.sh | sh";
const DOCKER = "docker run -d -v ./data:/data -p 3000:3000 ghcr.io/dvorinka/dash:latest";
export function Install() {
return (
<div className="grid gap-2.5 md:grid-cols-2">
<Reveal className="overflow-hidden rounded-xl border border-line bg-surface/50">
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
one-liner
</span>
<CopyButton text={CURL} label="Copy install command" />
</div>
<pre className="overflow-x-auto p-4 font-mono text-[12.5px] leading-7 text-dim">
<span className="text-text">$</span> curl -fsSL https://raw.githubusercontent.com/
{"\n"} Dvorinka/Dash/main/install.sh | sh{"\n"}
<span className="text-faint"># installs into ./dash, serves :3000</span>
</pre>
</Reveal>
<Reveal delay={0.08} className="overflow-hidden rounded-xl border border-line bg-surface/50">
<div className="flex items-center justify-between border-b border-line px-4 py-2.5">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
docker
</span>
<CopyButton text={DOCKER} label="Copy docker command" />
</div>
<pre className="overflow-x-auto p-4 font-mono text-[12.5px] leading-7 text-dim">
<span className="text-text">$</span> docker run -d -v ./data:/data \{"\n"}
{" "}-p 3000:3000 \{"\n"}
{" "}ghcr.io/dvorinka/dash:latest
</pre>
</Reveal>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { useEffect, useState } from "react";
/* Small live mocks for feature rows - data-shaped divs, no images. */
function useReduced() {
const [r, setR] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setR(mq.matches);
const fn = (e: MediaQueryListEvent) => setR(e.matches);
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}, []);
return r;
}
function usePing(base: number, spread: number, ms: number) {
const [v, setV] = useState(base);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(() => setV(Math.round(base + (Math.random() - 0.4) * spread)), ms);
return () => clearInterval(t);
}, [base, spread, ms, reduced]);
return v;
}
function HeartbeatStrip({ seed, ms }: { seed: number; ms: number }) {
const [bars, setBars] = useState<number[]>(() =>
Array.from({ length: 32 }, (_, k) => ((k * seed) % 23 === 0 ? 0 : 1)),
);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(
() => setBars((b) => [...b.slice(1), Math.random() > 0.045 ? 1 : 0]),
ms,
);
return () => clearInterval(t);
}, [seed, ms, reduced]);
return (
<div className="flex h-6 flex-1 items-end gap-[2px]">
{bars.map((v, k) => (
<span
key={k}
className={`flex-1 rounded-[1.5px] transition-colors duration-300 ${v ? "bg-up" : "bg-down"}`}
style={{ height: v ? `${55 + ((k * 29) % 45)}%` : "100%", opacity: v ? 0.8 : 1 }}
/>
))}
</div>
);
}
const MONITORS = [
{ name: "gitea.tdvorak.dev", kind: "https · 30s", up: "99.98", base: 38, seed: 3 },
{ name: "api.internal", kind: "tcp · 60s", up: "100.00", base: 12, seed: 7 },
{ name: "pihole.local", kind: "keyword · 60s", up: "99.62", base: 24, seed: 11 },
];
function MonitorRow({ m, i }: { m: (typeof MONITORS)[number]; i: number }) {
const ping = usePing(m.base, 18, 1700 + i * 300);
return (
<div className="flex items-center gap-4 px-4 py-3">
<div className="w-36 shrink-0">
<div className="truncate font-mono text-[12px]">{m.name}</div>
<div className="font-mono text-[10px] text-faint">{m.kind}</div>
</div>
<HeartbeatStrip seed={m.seed} ms={1300 + i * 250} />
<div className="w-14 text-right font-mono text-[11px] tabular-nums text-dim">{ping} ms</div>
<div className="w-16 rounded border border-line px-1.5 py-0.5 text-center font-mono text-[10px] text-up">
{m.up}%
</div>
</div>
);
}
export function MonitorMock() {
return (
<div className="divide-y divide-line rounded-xl border border-line bg-surface/50">
{MONITORS.map((m, i) => (
<MonitorRow key={m.name} m={m} i={i} />
))}
</div>
);
}
const DOMAINS = [
{ name: "tdvorak.dev", days: 260, pct: 71, tags: ["porkbun", "cloudflare", "tls 81d"] },
{ name: "kan.tdvorak.dev", days: 260, pct: 71, tags: ["porkbun", "cloudflare", "tls 81d"] },
{ name: "old-legacy.net", days: 12, pct: 8, tags: ["godaddy", "ns1", "tls 9d"], warn: true },
];
export function DomainMock() {
return (
<div className="divide-y divide-line rounded-xl border border-line bg-surface/50">
{DOMAINS.map((d) => (
<div key={d.name} className="flex items-center gap-4 px-4 py-3.5">
<div className="w-36 shrink-0">
<div className="truncate font-mono text-[12px]">{d.name}</div>
<div className="mt-1.5 h-1 w-full overflow-hidden rounded bg-iconbg">
<div
className={`h-full rounded ${d.warn ? "bg-down" : "bg-accent"}`}
style={{ width: `${d.pct}%` }}
/>
</div>
</div>
<span
className={`font-mono text-[12px] tabular-nums ${d.warn ? "text-down" : "text-dim"}`}
>
{d.days}d
</span>
<div className="ml-auto hidden gap-1.5 sm:flex">
{d.tags.map((t) => (
<span
key={t}
className="rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-faint"
>
{t}
</span>
))}
</div>
</div>
))}
</div>
);
}
export function SystemMock() {
const cpu = usePing(37, 30, 1900);
const mem = usePing(61, 6, 2600);
const R = 44;
const C = 2 * Math.PI * R;
return (
<div className="flex items-center gap-6 rounded-xl border border-line bg-surface/50 p-5">
<div className="relative h-28 w-28 shrink-0">
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
<circle cx="50" cy="50" r={R} fill="none" stroke="var(--color-iconbg)" strokeWidth="7" />
<circle
cx="50"
cy="50"
r={R}
fill="none"
stroke="var(--color-accent)"
strokeWidth="7"
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={C - (C * Math.min(cpu, 100)) / 100}
style={{ transition: "stroke-dashoffset 0.9s cubic-bezier(0.2,0,0,1)" }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-xl font-medium tabular-nums">{cpu}%</span>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-faint">cpu</span>
</div>
</div>
<div className="min-w-0 flex-1 space-y-3.5">
<div>
<div className="mb-1 flex justify-between font-mono text-[10px] text-faint">
<span>memory</span>
<span className="tabular-nums">{mem}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded bg-iconbg">
<div className="h-full rounded bg-up/80 transition-all duration-700" style={{ width: `${mem}%` }} />
</div>
</div>
<div>
<div className="mb-1 flex justify-between font-mono text-[10px] text-faint">
<span>disk /data</span>
<span>44%</span>
</div>
<div className="h-1.5 overflow-hidden rounded bg-iconbg">
<div className="h-full rounded bg-accent/80" style={{ width: "44%" }} />
</div>
</div>
<div className="flex items-center justify-between font-mono text-[10px] text-faint">
<span>8 containers</span>
<span className="tabular-nums">load 0.42 · up 14d</span>
</div>
</div>
</div>
);
}
const ALERTS: { at: string; text: string; kind: "ok" | "down" | "warn" }[] = [
{ at: "12:04:11", text: "pihole.local recovered", kind: "ok" },
{ at: "12:03:48", text: "pihole.local is down", kind: "down" },
{ at: "11:58:02", text: "old-legacy.net expires in 12d", kind: "warn" },
];
const ALERT_STYLE: Record<(typeof ALERTS)[number]["kind"], string> = {
ok: "text-up",
down: "text-destructive",
warn: "text-down",
};
export function StatusBand() {
const [i, setI] = useState(0);
const reduced = useReduced();
useEffect(() => {
if (reduced) return;
const t = setInterval(() => setI((v) => (v + 1) % ALERTS.length), 3000);
return () => clearInterval(t);
}, [reduced]);
const a = ALERTS[i];
return (
<div className="rounded-xl border border-line bg-surface/50 p-5">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
status page · public
</span>
<span className="rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-faint">
/status/lab
</span>
</div>
<div className="mt-4 rounded-md bg-up/90 px-3 py-2 text-center text-[12.5px] font-medium text-[#052e16]">
All systems operational
</div>
<div className="mt-4 space-y-2">
{["edge", "api", "media"].map((s) => (
<div key={s} className="flex items-center gap-2.5 font-mono text-[11px] text-dim">
<span className="h-1.5 w-1.5 rounded-full bg-up animate-pulse-dot" />
{s}
<span className="ml-auto text-faint">operational</span>
</div>
))}
</div>
<div className="mt-5 border-t border-line pt-3.5">
<div className="flex items-center gap-2 font-mono text-[10.5px]">
<span className="text-faint">{a.at}</span>
<span className={`truncate ${ALERT_STYLE[a.kind]}`}>{a.text}</span>
<span className="ml-auto shrink-0 rounded border border-line px-1.5 py-0.5 text-[9.5px] text-faint">
→ slack · ntfy · smtp
</span>
</div>
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
export function Nav() {
return (
<nav className="sticky top-0 z-20 border-b border-line bg-bg/80 backdrop-blur-md">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-2.5 px-5">
<a href="#" className="flex items-center gap-2 text-[14.5px] font-semibold tracking-tight">
<img src="/dash.svg" alt="" width={20} height={20} className="rounded-[5px]" />
Dash
</a>
<div className="ml-4 hidden gap-0.5 sm:flex">
{[
["Boards", "#renderers"],
["Monitors", "#monitors"],
["Stack", "#stack"],
["Install", "#install"],
].map(([label, href]) => (
<a
key={href}
href={href}
className="rounded-md px-2.5 py-1.5 text-[13px] text-dim transition-colors hover:bg-surface-hover hover:text-text"
>
{label}
</a>
))}
<a
href="https://github.com/Dvorinka/Dash/tree/main/docs"
className="rounded-md px-2.5 py-1.5 text-[13px] text-dim transition-colors hover:bg-surface-hover hover:text-text"
>
Docs
</a>
</div>
<div className="ml-auto flex items-center gap-2">
<a
href="https://github.com/Dvorinka/Dash"
aria-label="GitHub repository"
className="flex h-[34px] w-[34px] items-center justify-center rounded-lg border border-line bg-surface text-dim transition-colors hover:border-line-strong hover:text-text"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
</a>
<a
href="#install"
className="flex h-[34px] items-center rounded-lg bg-text px-3.5 text-[13px] font-medium text-bg transition-transform duration-100 active:scale-[0.97]"
>
Get started
</a>
</div>
</div>
</nav>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
/* Same state, four skins - the product's renderer seam, playable in-page. */
type Mode = "bento" | "cards" | "index" | "console";
const MODES: Mode[] = ["bento", "cards", "index", "console"];
const ITEMS = [
{ name: "Plex", host: "plex.local", letter: "P", tint: "#e5a00d" },
{ name: "Jellyfin", host: "jf.local", letter: "J", tint: "#aa5cc3" },
{ name: "Pi-hole", host: "pihole.local", letter: "P", tint: "#f05454" },
{ name: "Gitea", host: "git.local", letter: "G", tint: "#609926" },
{ name: "Grafana", host: "graf.local", letter: "G", tint: "#f46800" },
];
const BENTO_SPAN = ["sm:col-span-2", "sm:col-span-2", "sm:col-span-2", "sm:col-span-3", "sm:col-span-3"];
function Chip({ up = true }: { up?: boolean }) {
return (
<span className="flex items-center gap-1.5 rounded border border-line px-1.5 py-0.5 font-mono text-[10px] text-dim">
<span className={`h-1.5 w-1.5 rounded-full ${up ? "bg-up animate-pulse-dot" : "bg-down"}`} />
up
</span>
);
}
export function RendererDemo() {
const [mode, setMode] = useState<Mode>("bento");
const locked = useRef(false);
useEffect(() => {
const t = setInterval(() => {
if (!locked.current) {
setMode((m) => MODES[(MODES.indexOf(m) + 1) % MODES.length]);
}
}, 3400);
return () => clearInterval(t);
}, []);
const pick = (m: Mode) => {
locked.current = true;
setMode(m);
};
const gridCls =
mode === "bento"
? "grid grid-cols-2 gap-2 sm:grid-cols-6"
: mode === "cards"
? "grid grid-cols-2 gap-2 sm:grid-cols-5"
: mode === "console"
? "grid grid-cols-1 gap-2 sm:grid-cols-3"
: "flex flex-col divide-y divide-line";
return (
<div className="rounded-xl border border-line bg-bg">
<div className="flex items-center gap-2 border-b border-line px-4 py-3">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-faint">
same board, four renderers
</span>
<div className="ml-auto flex gap-1">
{MODES.map((m) => (
<button
key={m}
type="button"
aria-pressed={mode === m}
onClick={() => pick(m)}
className={`rounded-md border px-2.5 py-1 font-mono text-[11px] transition-colors duration-150 ${
mode === m
? "border-line-strong bg-surface-hover text-text"
: "border-transparent text-faint hover:text-dim"
}`}
>
{m}
</button>
))}
</div>
</div>
<div className="p-4">
<div className={gridCls}>
{ITEMS.map((it, k) => (
<motion.div
key={it.name}
layout
transition={{ duration: 0.5, ease: [0.2, 0, 0, 1] }}
className={mode === "index" ? "" : mode === "bento" ? `col-span-1 ${BENTO_SPAN[k]}` : "col-span-1"}
>
<motion.div
key={mode}
initial={{ opacity: 0.4 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className={
mode === "index"
? "flex items-center gap-4 py-3"
: mode === "console"
? "flex items-center gap-3 rounded-[4px] border border-line px-3 py-2.5 font-mono"
: "flex items-center gap-3 rounded-lg border border-line bg-surface p-3"
}
>
{mode === "index" ? (
<>
<span className="font-mono text-[11px] text-faint">0{k + 1}</span>
<span className="text-[14px] font-medium tracking-tight">{it.name}</span>
<span className="hidden font-mono text-[11px] text-faint sm:block">{it.host}</span>
<span className="ml-auto"><Chip /></span>
</>
) : mode === "console" ? (
<>
<span className="text-[11px] text-text">{it.name.toLowerCase()}</span>
<span className="text-[10px] text-faint">{it.host}</span>
<span className="ml-auto text-[10px] text-up">[ up ]</span>
</>
) : (
<>
<span
className={`flex shrink-0 items-center justify-center rounded-md bg-iconbg font-semibold ${mode === "cards" ? "h-7 w-7 text-[11px]" : "h-9 w-9 text-sm"}`}
style={{ color: it.tint }}
>
{it.letter}
</span>
<span className="min-w-0">
<span className={`block truncate font-medium ${mode === "cards" ? "text-[12px]" : "text-[13px]"}`}>
{it.name}
</span>
{mode === "bento" && (
<span className="block truncate font-mono text-[10.5px] text-faint">{it.host}</span>
)}
</span>
<span className="ml-auto"><Chip /></span>
</>
)}
</motion.div>
</motion.div>
))}
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { motion } from "motion/react";
import type { ReactNode } from "react";
export function Reveal({
children,
delay = 0,
className,
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.55, delay, ease: [0.2, 0, 0, 1] }}
>
{children}
</motion.div>
);
}
+1
View File
@@ -0,0 +1 @@
declare module "*.css";
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10
View File
@@ -0,0 +1,10 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
/** @type {import('next').NextConfig} */
const nextConfig = {
// repo has a root package-lock.json too - keep Next's tracing inside landing/
outputFileTracingRoot: dirname(fileURLToPath(import.meta.url)),
};
export default nextConfig;
+1834
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "dash-landing",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"motion": "^13.4.4",
"next": "^15.5.26",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3"
}
}
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="104" fill="#0a0a0a"/>
<g fill="#3b82f6">
<rect x="120" y="120" width="124" height="124" rx="28"/>
<rect x="268" y="120" width="124" height="124" rx="28" fill="#6b9ef8"/>
<rect x="120" y="268" width="124" height="124" rx="28" fill="#1d4fd7"/>
<rect x="268" y="268" width="124" height="124" rx="28"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 431 B

+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
".claude/**",
"tools/oxlint/anti-slop/**",
"packages/api-client/src/schema.d.ts",
"landing/next-env.d.ts",
"**/dist/**",
"**/node_modules/**",
],