Merge pull request #3 from Dvorinka/feat/phase5

Phase 5: alerts, agent depth, subdomains, auth, boards, i18n
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-22 12:51:44 +02:00
commit 0fa2ea82d8
82 files changed
+4897 -546

No files matched your search

+15 -8
View File
@@ -1,25 +1,32 @@
# Dash
A self-hosted homelab dashboard. CasaOS-grade ease of use, Vercel-grade design.
Single container, UI-managed, no YAML, no auth.
Single container, UI-managed, no YAML. Optional auth.
![Dash board](docs/screenshots/board.png)
![Dash demo](docs/demo.gif)
## Features
- **Service board** — sections, drag-and-drop, multi-URL services, icon upload,
four board renderers (Bento, Cards, Index, Console), dark/light themes
- **Service board** — multiple boards, sections, drag-and-drop, multi-URL
services, icon upload, four renderers (Bento, Cards, Index, Console),
dark/light themes, custom accent/wallpaper/CSS
- **Uptime monitors** — HTTP, TCP, ping, DNS, keyword, JSON-path, and push
checks on a schedule, with heartbeat history and uptime stats
checks on a schedule, heartbeat history, uptime graphs, per-monitor alert
rules
- **Domain watch** — WHOIS/RDAP, DNS records, TLS certificate expiry,
registrar and provider detection
registrar and provider detection, CT-log subdomain discovery
- **System monitoring** — lightweight `dash-agent` pushes CPU, memory, disk,
network, load, temperatures, and Docker container state
network, load, temperatures, per-container stats, and SMART/ZFS/GPU when
available
- **Incidents & status pages** — manual and automatic incidents, maintenance
windows, public `/status/:slug` pages, SVG badges
- **Alerts** — generic webhook notifications (Slack, Discord, ntfy compatible)
- **Alerts** — webhook, Slack, Discord, Telegram, Gotify, ntfy, and SMTP
transports
- **Observability** — Prometheus `/api/metrics`, JSON export, CSV bulk import,
importers for Homepage, Homarr, and Dashy configs
- **Optional auth** — local username/password with bcrypt + session cookies,
off by default; public status pages stay open either way
- **i18n + PWA** — English and Czech UI, installable manifest/service worker
## Run
+17 -18
View File
@@ -205,41 +205,40 @@ Beszel-style server monitoring, push-based.
**Exit:** a stranger can `docker run`, import their Homepage config, and have a working board in under two minutes.
## Phase 5 — Post-1.0 (planned, demand-confirmed 2026-09-21)
## Phase 5 — Post-1.0 (shipped 2026-09-22)
All items below confirmed in scope. Sequenced by dependency and risk —
quick wins first, structure-changing work last. Nothing here is built yet.
All items shipped on `feat/phase5`.
### P5a — Quick wins (frontend-heavy, no schema risk)
- [ ] **Uptime graphs** — monitor detail renders 24h/30d heartbeat bars +
- [x] **Uptime graphs** — monitor detail renders 24h/30d heartbeat bars +
latency line (recharts, already a dep). Data: existing
`/api/monitors/:id/heartbeats` + uptime stats. Frontend only.
- [ ] **iframe widget** — `config.url`, sandboxed `<iframe>` tile in the
- [x] **iframe widget** — `config.url`, sandboxed `<iframe>` tile in the
widget registry; local type, no backend fetcher.
- [ ] **Generic JSON-path widget** — backend fetcher: GET `config.url` with
- [x] **Generic JSON-path widget** — backend fetcher: GET `config.url` with
optional headers, extract `config.path` (dot/bracket path), display
value + optional label/format. Covers most "is API X alive" asks.
- [ ] **Custom CSS / accent / wallpaper** — settings keys `accent`,
- [x] **Custom CSS / accent / wallpaper** — settings keys `accent`,
`custom_css`, `wallpaper_url`; injected as CSS var override + `<style>`
+ board background layer. Respect reduced-motion/contrast defaults.
- [ ] **PWA** — `manifest.webmanifest` (icons, name, theme color), minimal
- [x] **PWA** — `manifest.webmanifest` (icons, name, theme color), minimal
service worker: cache-first for static assets, network-first for /api.
No offline board editing — read-only graceful degradation.
- [ ] **Demo GIF** — scripted playwright walkthrough (board → monitors →
- [x] **Demo GIF** — scripted playwright walkthrough (board → monitors →
status page) rendered via ffmpeg; committed to `docs/screenshots/`.
### P5b — Alert depth
- [ ] **SMTP transport** — `net/smtp` stdlib (STARTTLS + plain-auth only,
- [x] **SMTP transport** — `net/smtp` stdlib (STARTTLS + plain-auth only,
no external dep). Settings: `smtp_host`, `smtp_port`, `smtp_user`,
`smtp_pass` (secret), `smtp_from`, `smtp_to`. Same event pipeline as
the webhook; test button reuses `/api/notify/test`.
- [ ] **Notifier presets** — refactor `notify.go` into a transport registry:
- [x] **Notifier presets** — refactor `notify.go` into a transport registry:
`webhook` (generic, current), `slack`, `discord`, `telegram`, `gotify`,
`ntfy`, `smtp`. Presets = field set + payload template per transport;
settings store per-transport config. UI: transport picker + fields.
- [ ] **Alert rules** — per-monitor JSON `alerts` config: consecutive
- [x] **Alert rules** — per-monitor JSON `alerts` config: consecutive
failures before down (default 1), latency-warn ms, cert-days threshold
for domains, mute flag. Scheduler evaluates rules before firing events;
maintenance windows keep priority. Migration adds nullable
@@ -247,23 +246,23 @@ quick wins first, structure-changing work last. Nothing here is built yet.
### P5c — Agent + domain depth
- [ ] **Per-container metrics** — agent calls Docker `/containers/{id}/stats?stream=false`
- [x] **Per-container metrics** — agent calls Docker `/containers/{id}/stats?stream=false`
(cpu%, mem used/limit) when docker.sock present; payload adds
`containers[].cpu`, `.memUsed`, `.memLimit` (JSON — no migration).
UI: containers table gets cpu/mem columns + optional sparkline.
- [ ] **SMART / ZFS / GPU** — optional collectors, all best-effort and
- [x] **SMART / ZFS / GPU** — optional collectors, all best-effort and
silently skipped when tooling is absent:
SMART via `smartctl -j` exec if installed; ZFS via `zpool status`/`-j`
parse; GPU via `/sys/class/drm` (amd/intel basic) and `nvidia-smi -j`
when present. No new hard deps — exec-or-skip.
- [ ] **Subdomain discovery** — crt.sh CT-log query + A/AAAA resolve for
- [x] **Subdomain discovery** — crt.sh CT-log query + A/AAAA resolve for
discovered names; `subdomains` table (domain_id, name, ips, first_seen,
last_seen). Domain detail: subdomains tab with probe status. Daily
sweep alongside the domain refresh job.
### P5d — Structure (big, changes product shape)
- [ ] **Auth (opt-in, email/username + password)** — deliberate constraint:
- [x] **Auth (opt-in, email/username + password)** — deliberate constraint:
stays disabled by default; enabling is a settings toggle. First enable
creates the admin user. `users` table (id, email, name, passhash
bcrypt/argon2id, created_at), session cookie (httpOnly, signed,
@@ -272,12 +271,12 @@ quick wins first, structure-changing work last. Nothing here is built yet.
and badges stay open. No OIDC, no SSO — local accounts only.
Security pass required: rate-limit login, constant-time compare,
session rotation.
- [ ] **Multiple boards** — `boards` table (id, name, slug, position),
- [x] **Multiple boards** — `boards` table (id, name, slug, position),
`sections.board_id` FK (migration backfills all rows to board 1).
Board switcher in the header (dropdown + `/b/:slug` route), settings
for default board. Monitors/domains/systems pages stay global — only
the service board multiplies.
- [ ] **Full i18n extraction** — every UI string moves into `en.ts` via the
- [x] **Full i18n extraction** — every UI string moves into `en.ts` via the
existing `t()` seam; mechanical pass over pages/dialogs/widgets.
Second locale (cs) lands in the same pass to prove the seam end-to-end.
+121
View File
@@ -0,0 +1,121 @@
package main
// Docker collector: container liveness plus per-container CPU/memory from
// /containers/{id}/stats?stream=false. All best-effort — no socket, no data.
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
const dockerSock = "/var/run/docker.sock"
var dockerClient = &http.Client{
Timeout: 4 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, "unix", dockerSock)
},
},
}
// dockerStatsJSON is the subset of /containers/{id}/stats we consume.
type dockerStatsJSON struct {
CPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
OnlineCPUs uint32 `json:"online_cpus"`
} `json:"cpu_stats"`
PreCPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
} `json:"precpu_stats"`
MemoryStats struct {
Usage uint64 `json:"usage"`
Limit uint64 `json:"limit"`
} `json:"memory_stats"`
}
// containerCPUPct applies the Docker dashboard formula: cpu delta over
// system delta, scaled by online CPUs. 0 when deltas don't advance.
func containerCPUPct(s *dockerStatsJSON) float64 {
cpuDelta := float64(s.CPUStats.CPUUsage.TotalUsage) - float64(s.PreCPUStats.CPUUsage.TotalUsage)
sysDelta := float64(s.CPUStats.SystemCPUUsage) - float64(s.PreCPUStats.SystemCPUUsage)
online := s.CPUStats.OnlineCPUs
if online == 0 {
online = 1
}
if cpuDelta <= 0 || sysDelta <= 0 {
return 0
}
return cpuDelta / sysDelta * float64(online) * 100
}
// readContainers lists containers and fans out to per-container stats.
// Stats fetches are bounded: 8 concurrent, each under the client timeout.
func readContainers() []containerStat {
if _, err := net.DialTimeout("unix", dockerSock, time.Second); err != nil {
return nil
}
res, err := dockerClient.Get("http://d/containers/json?all=1")
if err != nil {
return nil
}
defer res.Body.Close()
var raw []struct {
ID string `json:"Id"`
Names []string `json:"Names"`
State string `json:"State"`
}
if json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&raw) != nil {
return nil
}
out := make([]containerStat, len(raw))
var wg sync.WaitGroup
sem := make(chan struct{}, 8)
for i, r := range raw {
name := ""
if len(r.Names) > 0 {
name = strings.TrimPrefix(r.Names[0], "/")
}
out[i] = containerStat{Name: name, State: r.State}
if r.State != "running" || r.ID == "" {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(i int, id string) {
defer wg.Done()
defer func() { <-sem }()
fillContainerStats(&out[i], id)
}(i, r.ID)
}
wg.Wait()
return out
}
// fillContainerStats fetches one container's one-shot stats into cs.
func fillContainerStats(cs *containerStat, id string) {
res, err := dockerClient.Get("http://d/containers/" + id + "/stats?stream=false")
if err != nil {
return
}
defer res.Body.Close()
var st dockerStatsJSON
if json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&st) != nil {
return
}
cs.CPU = containerCPUPct(&st)
cs.MemUsed = float64(st.MemoryStats.Usage)
cs.MemLimit = float64(st.MemoryStats.Limit)
}
+230
View File
@@ -0,0 +1,230 @@
package main
// Optional collectors: SMART (smartctl), ZFS (zpool), GPU (nvidia-smi or
// /sys/class/drm). Every collector is exec-or-skip — missing tools or
// permissions produce nil, never errors, and each exec carries a timeout so a
// wedged tool can't stall reporting.
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// runOut executes a command with a timeout; empty output on any failure.
func runOut(timeout time.Duration, name string, args ...string) []byte {
if _, err := exec.LookPath(name); err != nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).Output()
if err != nil {
return nil
}
return out
}
// ---------- SMART ----------
type smartctlScan struct {
Devices []struct {
Name string `json:"name"`
} `json:"devices"`
}
type smartctlAll struct {
ModelName string `json:"model_name"`
SmartStatus *struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
Temperature *struct {
Current float64 `json:"current"`
} `json:"temperature"`
}
// readSMART scans devices via smartctl -j --scan, then reads each drive.
// Needs smartmontools installed and usually root or sudo rights for /dev.
func readSMART() []smartDisk {
scan := runOut(5*time.Second, "smartctl", "-j", "--scan")
if scan == nil {
return nil
}
var sc smartctlScan
if json.Unmarshal(scan, &sc) != nil || len(sc.Devices) == 0 {
return nil
}
out := make([]smartDisk, 0, len(sc.Devices))
for i, d := range sc.Devices {
if i >= 8 { // bound the fan-out
break
}
b := runOut(4*time.Second, "smartctl", "-j", "-a", d.Name)
if b == nil {
continue
}
var a smartctlAll
if json.Unmarshal(b, &a) != nil {
continue
}
sd := smartDisk{Device: d.Name, Model: a.ModelName}
if a.SmartStatus != nil {
p := a.SmartStatus.Passed
sd.Passed = &p
}
if a.Temperature != nil {
sd.TempC = a.Temperature.Current
}
out = append(out, sd)
}
return out
}
// ---------- ZFS ----------
// readZFS prefers `zpool list -j` (OpenZFS 2.3+), falls back to the stable
// `-H -p` text format. Parse failures degrade to nil.
func readZFS() []zfsPool {
if b := runOut(4*time.Second, "zpool", "list", "-j", "--json-int",
"-o", "name,health,size,free"); b != nil {
if pools := parseZpoolJSON(b); pools != nil {
return pools
}
}
return parseZpoolText(runOut(4*time.Second,
"zpool", "list", "-H", "-p", "-o", "name,health,size,free"))
}
// parseZpoolJSON reads {"pools":{"name":{"properties":{...}}}}.
func parseZpoolJSON(b []byte) []zfsPool {
var doc struct {
Pools map[string]struct {
Properties map[string]struct {
Value any `json:"value"`
} `json:"properties"`
} `json:"pools"`
}
if json.Unmarshal(b, &doc) != nil || len(doc.Pools) == 0 {
return nil
}
var out []zfsPool
num := func(v any) float64 {
switch n := v.(type) {
case float64:
return n
case string:
f, _ := strconv.ParseFloat(n, 64)
return f
}
return 0
}
for name, p := range doc.Pools {
z := zfsPool{Name: name}
if v, ok := p.Properties["health"]; ok {
if s, ok := v.Value.(string); ok {
z.Health = s
}
}
if v, ok := p.Properties["size"]; ok {
z.Size = num(v.Value)
}
if v, ok := p.Properties["free"]; ok {
z.Free = num(v.Value)
}
out = append(out, z)
}
return out
}
// parseZpoolText reads `zpool list -H -p` rows: name\thealth\tsize\tfree.
func parseZpoolText(b []byte) []zfsPool {
var out []zfsPool
for _, line := range strings.Split(string(b), "\n") {
f := strings.Split(line, "\t")
if len(f) < 4 {
continue
}
size, _ := strconv.ParseFloat(f[2], 64)
free, _ := strconv.ParseFloat(f[3], 64)
out = append(out, zfsPool{Name: f[0], Health: f[1], Size: size, Free: free})
}
return out
}
// ---------- GPU ----------
// readGPU prefers nvidia-smi (CSV query); otherwise walks /sys/class/drm for
// AMD/Intel cards exposing gpu_busy_percent and hwmon temps.
func readGPU() []gpuStat {
if _, err := exec.LookPath("nvidia-smi"); err == nil {
return readNvidiaGPU()
}
return readGPUSysfs()
}
func readNvidiaGPU() []gpuStat {
return parseNvidiaCSV(runOut(4*time.Second, "nvidia-smi",
"--query-gpu=name,temperature.gpu,utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits"))
}
// parseNvidiaCSV reads rows: name, tempC, util%, memUsed MiB, memTotal MiB.
func parseNvidiaCSV(b []byte) []gpuStat {
var out []gpuStat
for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
f := strings.Split(line, ",")
if len(f) < 5 {
continue
}
g := gpuStat{Name: strings.TrimSpace(f[0])}
g.TempC, _ = strconv.ParseFloat(strings.TrimSpace(f[1]), 64)
g.UtilPct, _ = strconv.ParseFloat(strings.TrimSpace(f[2]), 64)
g.MemUsed, _ = strconv.ParseFloat(strings.TrimSpace(f[3]), 64)
g.MemTotal, _ = strconv.ParseFloat(strings.TrimSpace(f[4]), 64)
g.MemUsed *= 1 << 20
g.MemTotal *= 1 << 20
out = append(out, g)
}
return out
}
// readGPUSysfs pulls util/temp for each card from DRM sysfs.
func readGPUSysfs() []gpuStat {
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
var out []gpuStat
for _, card := range cards {
dev := card + "/device"
g := gpuStat{Name: filepath.Base(card)}
if b, err := os.ReadFile(dev + "/gpu_busy_percent"); err == nil {
g.UtilPct, _ = strconv.ParseFloat(strings.TrimSpace(string(b)), 64)
}
// hwmon: first temp1_input under device/hwmon/hwmonN.
if hws, err := filepath.Glob(dev + "/hwmon/hwmon*"); err == nil {
for _, hw := range hws {
if b, err := os.ReadFile(hw + "/temp1_input"); err == nil {
if milli, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64); err == nil {
g.TempC = milli / 1000
}
break
}
}
}
// Driver name via uevent DRIVER= for a friendlier label.
if b, err := os.ReadFile(dev + "/uevent"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "DRIVER="); ok {
g.Name += " (" + v + ")"
}
}
}
if g.UtilPct > 0 || g.TempC > 0 {
out = append(out, g)
}
}
return out
}
+33 -37
View File
@@ -9,13 +9,11 @@ package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"runtime"
@@ -96,11 +94,40 @@ type sample struct {
Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps,omitempty"`
Containers []containerStat `json:"containers,omitempty"`
Smart []smartDisk `json:"smart,omitempty"`
ZFS []zfsPool `json:"zfs,omitempty"`
GPU []gpuStat `json:"gpu,omitempty"`
}
type containerStat struct {
Name string `json:"name"`
State string `json:"state"`
CPU float64 `json:"cpu,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemLimit float64 `json:"memLimit,omitempty"`
}
// Optional-collector shapes — mirror api.SmartDisk/ZFSPool/GPUStat.
type smartDisk struct {
Device string `json:"device"`
Model string `json:"model"`
Passed *bool `json:"passed,omitempty"`
TempC float64 `json:"tempC,omitempty"`
}
type zfsPool struct {
Name string `json:"name"`
Health string `json:"health"`
Size float64 `json:"size"`
Free float64 `json:"free"`
}
type gpuStat struct {
Name string `json:"name"`
TempC float64 `json:"tempC,omitempty"`
UtilPct float64 `json:"utilPct,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemTotal float64 `json:"memTotal,omitempty"`
}
type collector struct {
@@ -141,6 +168,9 @@ func (c *collector) sample(interval int) sample {
s.UptimeS = readUptime()
s.Temps = readTemps()
s.Containers = readContainers()
s.Smart = readSMART()
s.ZFS = readZFS()
s.GPU = readGPU()
return s
}
@@ -325,38 +355,4 @@ func readTemps() map[string]float64 {
return out
}
// readContainers lists Docker containers via the unix socket when present.
func readContainers() []containerStat {
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
return nil
}
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
},
},
}
res, err := client.Get("http://d/containers/json?all=1")
if err != nil {
return nil
}
defer res.Body.Close()
var raw []struct {
Names []string `json:"Names"`
State string `json:"State"`
}
if json.NewDecoder(res.Body).Decode(&raw) != nil {
return nil
}
out := make([]containerStat, 0, len(raw))
for _, r := range raw {
name := ""
if len(r.Names) > 0 {
name = strings.TrimPrefix(r.Names[0], "/")
}
out = append(out, containerStat{Name: name, State: r.State})
}
return out
}
+48
View File
@@ -0,0 +1,48 @@
package main
import "testing"
func TestContainerCPUPct(t *testing.T) {
var s dockerStatsJSON
s.CPUStats.CPUUsage.TotalUsage = 300
s.PreCPUStats.CPUUsage.TotalUsage = 100
s.CPUStats.SystemCPUUsage = 1000
s.PreCPUStats.SystemCPUUsage = 500
s.CPUStats.OnlineCPUs = 4
if got := containerCPUPct(&s); got != 160 {
t.Fatalf("expected 160%%, got %v", got)
}
// No deltas -> 0, no div-by-zero.
s.PreCPUStats.CPUUsage.TotalUsage = s.CPUStats.CPUUsage.TotalUsage
s.PreCPUStats.SystemCPUUsage = s.CPUStats.SystemCPUUsage
if got := containerCPUPct(&s); got != 0 {
t.Fatalf("expected 0, got %v", got)
}
}
func TestParseZpoolText(t *testing.T) {
out := parseZpoolText([]byte("tank\tONLINE\t1000000000\t400000000\nfast\tDEGRADED\t500\t100\n"))
if len(out) != 2 || out[0].Name != "tank" || out[0].Size != 1e9 || out[1].Health != "DEGRADED" {
t.Fatalf("bad parse: %+v", out)
}
if parseZpoolText(nil) != nil {
t.Fatal("nil input should yield nil")
}
}
func TestParseZpoolJSON(t *testing.T) {
b := []byte(`{"output_version":{"major":0},"pools":{"tank":{"name":"tank","properties":{
"health":{"value":"ONLINE"},"size":{"value":1000000000},"free":{"value":250000000}}}}}`)
out := parseZpoolJSON(b)
if len(out) != 1 || out[0].Health != "ONLINE" || out[0].Free != 250000000 {
t.Fatalf("bad parse: %+v", out)
}
}
func TestParseNvidiaCSV(t *testing.T) {
out := parseNvidiaCSV([]byte("RTX 3080, 62, 14, 2048, 10240\n"))
if len(out) != 1 || out[0].Name != "RTX 3080" || out[0].TempC != 62 ||
out[0].MemUsed != 2048*1<<20 || out[0].MemTotal != 10240*1<<20 {
t.Fatalf("bad parse: %+v", out)
}
}
+4 -4
View File
@@ -42,10 +42,10 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
+10
View File
@@ -99,18 +99,28 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+46
View File
@@ -0,0 +1,46 @@
package api
import "encoding/json"
// alertRules is the per-target rules blob stored in monitors.alerts /
// domains.alerts. Zero values keep pre-rules behavior: first failure alerts,
// no latency rule, cert threshold = alertDaysBefore, unmuted.
//
// consecutiveFailures — monitor must fail N checks in a row before it flips down
// latencyWarnMs — notify once when an up check's ping crosses above this
// certDays — domain TLS-expiry threshold (overrides alertDaysBefore)
// mute — suppress notifications; incidents/status still record
type alertRules struct {
ConsecutiveFailures int `json:"consecutiveFailures"`
LatencyWarnMs int `json:"latencyWarnMs"`
CertDays int `json:"certDays"`
Mute bool `json:"mute"`
}
func parseAlertRules(raw json.RawMessage) alertRules {
var r alertRules
_ = json.Unmarshal(raw, &r)
return r
}
// consecutiveDowns counts failed heartbeats, newest-first, stopping at the
// first up. The just-written heartbeat is included.
func (s *Server) consecutiveDowns(monitorID string, limit int) int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM (
SELECT status FROM heartbeats WHERE monitor_id = ?
ORDER BY checked_at DESC, rowid DESC LIMIT ?)
WHERE status = 'down'`, monitorID, limit).Scan(&n)
return n
}
// prevPing returns the ping of the heartbeat before the latest, or -1.
func (s *Server) prevPing(monitorID string) int {
var p int
if err := s.db.QueryRow(`SELECT ping_ms FROM heartbeats
WHERE monitor_id = ? ORDER BY checked_at DESC, rowid DESC
LIMIT 1 OFFSET 1`, monitorID).Scan(&p); err != nil {
return -1
}
return p
}
+324
View File
@@ -0,0 +1,324 @@
package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tdvorak/dash/internal/domain"
)
// ---------- transports ----------
func TestNotifyTransports(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// One receiver records body + headers + query per request.
type hit struct {
body map[string]any
raw string
title string
path string
}
got := make(chan hit, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
buf, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(buf, &m)
got <- hit{body: m, raw: string(buf), title: r.Header.Get("Title"), path: r.URL.RequestURI()}
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
put := func(kv string) {
if res, _ := do(t, "PUT", srv.URL+"/api/settings", kv); res.StatusCode != http.StatusOK {
t.Fatalf("put %s: %d", kv, res.StatusCode)
}
}
// slack + discord + gotify + ntfy all configured; test fires each.
put(`{"notify_slack":{"url":"` + hook.URL + `/slack"}}`)
put(`{"notify_discord":{"url":"` + hook.URL + `/discord"}}`)
put(`{"notify_gotify":{"server":"` + hook.URL + `","token":"gtok"}}`)
put(`{"notify_ntfy":{"server":"` + hook.URL + `","topic":"alerts"}}`)
res, out := do(t, "POST", srv.URL+"/api/notify/test", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("notify test: %d", res.StatusCode)
}
results := out["results"].(map[string]any)
for _, name := range []string{"slack", "discord", "gotify", "ntfy"} {
if results[name] != "ok" {
t.Fatalf("%s: %v", name, results[name])
}
}
seen := map[string]hit{}
for i := 0; i < 4; i++ {
select {
case h := <-got:
seen[h.path] = h
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for transports")
}
}
if seen["/slack"].body["text"] == "" {
t.Fatalf("slack payload missing text: %v", seen["/slack"].body)
}
if seen["/discord"].body["content"] == "" {
t.Fatalf("discord payload missing content: %v", seen["/discord"].body)
}
if !strings.HasPrefix(seen["/message?token=gtok"].path, "/message?token=") {
t.Fatalf("gotify hit missing: %v", seen)
}
if h := seen["/alerts"]; h.raw != "Dash test notification" || h.title != "dash" {
t.Fatalf("ntfy payload wrong: %q title=%q", h.raw, h.title)
}
// Legacy string webhook still works alongside presets.
put(`{"notify_webhook":"` + hook.URL + `/legacy"}`)
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"webhook"}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("webhook test: %d", res.StatusCode)
}
select {
case h := <-got:
if h.path != "/legacy" || h.body["event"] != "test" {
t.Fatalf("legacy webhook payload: %v", h.body)
}
case <-time.After(3 * time.Second):
t.Fatal("legacy webhook never fired")
}
// Named unconfigured transport -> 400.
res, _ = do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for unconfigured smtp, got %d", res.StatusCode)
}
}
// fakeSMTP is the smallest lawful SMTP server: EHLO/MAIL/RCPT/DATA/QUIT.
func fakeSMTP(t *testing.T) (addr string, gotMsg <-chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
msgs := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
r := bufio.NewReader(conn)
fmt.Fprint(conn, "220 fake\r\n")
var data strings.Builder
inData := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
if inData {
if line == "." {
inData = false
fmt.Fprint(conn, "250 queued\r\n")
continue
}
data.WriteString(line + "\n")
continue
}
switch {
case strings.HasPrefix(line, "EHLO"):
fmt.Fprint(conn, "250-fake\r\n250 OK\r\n")
case strings.HasPrefix(line, "MAIL"), strings.HasPrefix(line, "RCPT"):
fmt.Fprint(conn, "250 OK\r\n")
case strings.HasPrefix(line, "DATA"):
fmt.Fprint(conn, "354 go\r\n")
inData = true
case strings.HasPrefix(line, "QUIT"):
fmt.Fprint(conn, "221 bye\r\n")
msgs <- data.String()
return
default:
fmt.Fprint(conn, "250 OK\r\n")
}
}
}()
return ln.Addr().String(), msgs
}
func TestNotifySMTP(t *testing.T) {
srv := testServer(t)
defer srv.Close()
addr, gotMsg := fakeSMTP(t)
host, port, _ := net.SplitHostPort(addr)
res, _ := do(t, "PUT", srv.URL+"/api/settings",
`{"notify_smtp":{"host":"`+host+`","port":"`+port+`","from":"dash@local","to":"me@local"}}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("put smtp: %d", res.StatusCode)
}
res, out := do(t, "POST", srv.URL+"/api/notify/test", `{"transport":"smtp"}`)
if res.StatusCode != http.StatusOK || out["results"].(map[string]any)["smtp"] != "ok" {
t.Fatalf("smtp test: %d %v", res.StatusCode, out)
}
select {
case msg := <-gotMsg:
if !strings.Contains(msg, "Subject: [Dash] Dash test notification") {
t.Fatalf("smtp message missing subject: %q", msg)
}
case <-time.After(3 * time.Second):
t.Fatal("smtp server never received the message")
}
}
// ---------- alert rules ----------
func TestAlertRulesConsecutiveAndMute(t *testing.T) {
srv := testServer(t)
defer srv.Close()
got := make(chan map[string]any, 8)
hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var m map[string]any
_ = json.NewDecoder(r.Body).Decode(&m)
got <- m
w.WriteHeader(http.StatusOK)
}))
defer hook.Close()
do(t, "PUT", srv.URL+"/api/settings", `{"notify_webhook":"`+hook.URL+`"}`)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
defer up.Close()
down := `{"url":"http://127.0.0.1:1","timeoutS":1}`
// Monitor requiring 2 consecutive failures before going down.
res, m := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Flap","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"consecutiveFailures":2}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create: %v", m)
}
id := m["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // -> up
do(t, "PATCH", srv.URL+"/api/monitors/"+id, down)
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 1st failure: hold
_, mv := do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "up" {
t.Fatalf("expected held status up after 1 failure, got %v", mv["status"])
}
do(t, "POST", srv.URL+"/api/monitors/"+id+"/check", "") // 2nd failure: down
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id, "")
if mv["status"] != "down" {
t.Fatalf("expected down after 2 failures, got %v", mv["status"])
}
select {
case body := <-got:
if body["event"] != "monitor.down" {
t.Fatalf("expected monitor.down, got %v", body["event"])
}
case <-time.After(3 * time.Second):
t.Fatal("monitor.down never fired after threshold")
}
// Muted monitor: status still flips, no notification.
res, m2 := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"Quiet","type":"http","url":"`+up.URL+`","timeoutS":1,"alerts":{"mute":true}}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create muted: %v", m2)
}
id2 := m2["id"].(string)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
do(t, "PATCH", srv.URL+"/api/monitors/"+id2, down)
do(t, "POST", srv.URL+"/api/monitors/"+id2+"/check", "")
_, mv = do(t, "GET", srv.URL+"/api/monitors/"+id2, "")
if mv["status"] != "down" {
t.Fatalf("muted monitor should still go down, got %v", mv["status"])
}
select {
case body := <-got:
if body["name"] == "Quiet" {
t.Fatal("muted monitor still notified")
}
case <-time.After(1500 * time.Millisecond):
}
}
func TestAlertRulesBadJSON(t *testing.T) {
srv := testServer(t)
defer srv.Close()
res, _ := do(t, "POST", srv.URL+"/api/monitors",
`{"name":"X","type":"http","url":"http://127.0.0.1:1","alerts":"nope"}`)
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid alerts, got %d", res.StatusCode)
}
}
// ---------- subdomains ----------
func TestSubdomainDiscovery(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Stub the network seams: CT returns candidates, resolver answers a few.
oldCT, oldRes := domain.CTNames, domain.ResolveIPs
domain.CTNames = func(_ context.Context, d string) ([]string, error) {
return []string{"api." + d + "\nwww." + d, "*." + d, "other.com"}, nil
}
domain.ResolveIPs = func(_ context.Context, name string) ([]string, error) {
if strings.HasPrefix(name, "api.") {
return []string{"10.0.0.1"}, nil
}
return []string{"93.184.216.34"}, nil
}
t.Cleanup(func() { domain.CTNames, domain.ResolveIPs = oldCT, oldRes })
res, d := do(t, "POST", srv.URL+"/api/domains", `{"name":"example.com"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create domain: %v", d)
}
id := d["id"].(string)
// First refresh ran discovery inline (sub_checked_at was NULL).
res, subs := do(t, "GET", srv.URL+"/api/domains/"+id+"/subdomains", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("list subdomains: %d", res.StatusCode)
}
list, _ := subs["subdomains"].([]any) // response is a bare array — check below
_ = list
// do() decodes objects only; re-fetch raw for the array.
raw, err := http.Get(srv.URL + "/api/domains/" + id + "/subdomains")
if err != nil {
t.Fatal(err)
}
var arr []map[string]any
_ = json.NewDecoder(raw.Body).Decode(&arr)
raw.Body.Close()
names := map[string][]string{}
for _, s := range arr {
ips := []string{}
for _, ip := range s["ips"].([]any) {
ips = append(ips, ip.(string))
}
names[s["name"].(string)] = ips
}
if len(names) != 3 || len(names["api.example.com"]) != 1 || names["api.example.com"][0] != "10.0.0.1" {
t.Fatalf("unexpected subdomains: %v", names)
}
if _, bad := names["other.com"]; bad {
t.Fatal("foreign name leaked into subdomains")
}
}
+15
View File
@@ -60,8 +60,20 @@ func (s *Server) routes() *gin.Engine {
r.Use(accessLog(s.log), gin.Recovery())
v1 := r.Group("/api")
v1.Use(s.requireAuth())
v1.GET("/healthz", s.healthz)
v1.GET("/auth/session", s.authSession)
v1.POST("/auth/setup", s.authSetup)
v1.POST("/auth/login", s.authLogin)
v1.POST("/auth/logout", s.authLogout)
v1.POST("/auth/disable", s.authDisable)
v1.GET("/boards", s.listBoards)
v1.POST("/boards", s.createBoard)
v1.PATCH("/boards/:id", s.updateBoard)
v1.DELETE("/boards/:id", s.deleteBoard)
v1.GET("/sections", s.listSections)
v1.POST("/sections", s.createSection)
v1.PATCH("/sections/:id", s.updateSection)
@@ -91,6 +103,8 @@ func (s *Server) routes() *gin.Engine {
v1.DELETE("/domains/:id", s.deleteDomain)
v1.POST("/domains/:id/refresh", s.refreshDomainH)
v1.GET("/domains/:id/checks", s.domainChecks)
v1.GET("/domains/:id/subdomains", s.listSubdomains)
v1.POST("/domains/:id/subdomains/refresh", s.refreshSubdomains)
v1.POST("/notify/test", s.notifyTest)
v1.GET("/systems", s.listSystemsH)
@@ -167,6 +181,7 @@ type Section struct {
Name string `json:"name"`
Position float64 `json:"position"`
Collapsed bool `json:"collapsed"`
BoardID string `json:"boardId"`
Items []Item `json:"items"`
}
+70
View File
@@ -786,3 +786,73 @@ func TestValidURL(t *testing.T) {
}
}
}
func TestBoards(t *testing.T) {
srv := testServer(t)
defer srv.Close()
// Default board exists and owns new sections.
var boards []Board
resp, err := http.Get(srv.URL + "/api/boards")
if err != nil || json.NewDecoder(resp.Body).Decode(&boards) != nil {
t.Fatalf("list boards: %v", err)
}
resp.Body.Close()
if len(boards) != 1 || boards[0].ID != "b_home" {
t.Fatalf("expected seeded home board, got %+v", boards)
}
_, sec := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
if sec["boardId"] != "b_home" {
t.Fatalf("section should default to home board: %v", sec["boardId"])
}
// Create a second board; slug is derived and unique.
_, b2 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b2["slug"] != "lab-net" {
t.Fatalf("bad slug: %v", b2["slug"])
}
_, b3 := do(t, "POST", srv.URL+"/api/boards", `{"name":"Lab Net"}`)
if b3["slug"] == b2["slug"] {
t.Fatalf("slug collision: %v", b3["slug"])
}
// Sections scope to their board.
_, s2 := do(t, "POST", srv.URL+"/api/sections", `{"name":"B","boardId":"`+b2["id"].(string)+`"}`)
if s2["boardId"] != b2["id"] {
t.Fatalf("section not scoped to board: %v", s2["boardId"])
}
var home, lab []Section
r1, _ := http.Get(srv.URL + "/api/sections?board=b_home")
_ = json.NewDecoder(r1.Body).Decode(&home)
r1.Body.Close()
r2, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r2.Body).Decode(&lab)
r2.Body.Close()
if len(home) != 1 || home[0].Name != "A" || len(lab) != 1 || lab[0].Name != "B" {
t.Fatalf("board filter broken: home=%v lab=%v", home, lab)
}
// Rename keeps the slug; deleting cascades sections.
_, rb := do(t, "PATCH", srv.URL+"/api/boards/"+b3["id"].(string), `{"name":"Renamed"}`)
if rb["name"] != "Renamed" || rb["slug"] == "renamed" {
t.Fatalf("rename should not change slug: %+v", rb)
}
res, _ := do(t, "DELETE", srv.URL+"/api/boards/"+b2["id"].(string), "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete board: %d", res.StatusCode)
}
var labAfter []Section
r3, _ := http.Get(srv.URL + "/api/sections?board=" + b2["id"].(string))
_ = json.NewDecoder(r3.Body).Decode(&labAfter)
r3.Body.Close()
if len(labAfter) != 0 {
t.Fatalf("sections should cascade with board: %v", labAfter)
}
// Down to two boards — delete both and the last delete must refuse.
do(t, "DELETE", srv.URL+"/api/boards/"+b3["id"].(string), "")
res, _ = do(t, "DELETE", srv.URL+"/api/boards/b_home", "")
if res.StatusCode != http.StatusBadRequest {
t.Fatalf("deleting last board should 400, got %d", res.StatusCode)
}
}
+261
View File
@@ -0,0 +1,261 @@
package api
// Opt-in local auth — disabled by default; enabling creates the first admin.
// Local username + bcrypt password only (no OIDC/SSO — deliberate non-goal).
// Sessions are random tokens in SQLite behind an httpOnly SameSite=Lax cookie;
// login is rate-limited per IP. Public endpoints (status pages, badges, push,
// agent ingest, metrics) bypass the middleware.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "dash_session"
sessionTTL = 7 * 24 * time.Hour
// Rotate when less than half the TTL remains.
sessionRotate = sessionTTL / 2
)
func (s *Server) authEnabled() bool {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'auth_enabled'`).Scan(&raw); err != nil {
return false
}
var on bool
_ = json.Unmarshal([]byte(raw), &on)
return on
}
func (s *Server) userCount() int {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n
}
// ---------- sessions ----------
func (s *Server) createSession(c *gin.Context, userID string) {
var tok [32]byte
_, _ = rand.Read(tok[:])
token := hex.EncodeToString(tok[:])
exp := time.Now().Add(sessionTTL)
_, _ = s.db.Exec(`INSERT INTO sessions (token, user_id, expires_at) VALUES (?,?,?)`,
token, userID, exp.UTC().Format("2006-01-02T15:04:05.000Z"))
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Expires: exp,
HttpOnly: true,
Secure: c.Request.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
}
// sessionUser resolves the cookie to a user, sliding-rotating near expiry.
// Returns "" when absent/expired.
func (s *Server) sessionUser(c *gin.Context) string {
ck, err := c.Cookie(sessionCookie)
if err != nil || ck == "" {
return ""
}
var userID, expires string
err = s.db.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token = ?`, ck).Scan(&userID, &expires)
if err != nil {
return ""
}
exp, err := time.Parse("2006-01-02T15:04:05.000Z", expires)
if err != nil || time.Now().After(exp) {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
return ""
}
if time.Until(exp) < sessionRotate {
// Rotate: drop the old token, issue a fresh one.
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
s.createSession(c, userID)
}
return userID
}
// ---------- rate limiting (login only, in-memory per-IP) ----------
var loginMu sync.Mutex
var loginHits = map[string][]time.Time{}
func loginAllowed(ip string) bool {
loginMu.Lock()
defer loginMu.Unlock()
cut := time.Now().Add(-time.Minute)
kept := loginHits[ip][:0]
for _, t := range loginHits[ip] {
if t.After(cut) {
kept = append(kept, t)
}
}
if len(kept) >= 5 {
loginHits[ip] = kept
return false
}
loginHits[ip] = append(kept, time.Now())
return true
}
// ---------- middleware ----------
// publicAPI lists /api paths reachable without a session. Auth endpoints must
// stay open (login itself); push/ingest/badge/metrics/public status carry
// their own tokens or are intentionally public.
func publicAPI(path string) bool {
switch {
case strings.HasPrefix(path, "/api/auth/"),
strings.HasPrefix(path, "/api/push/"),
path == "/api/systems/ingest",
strings.HasPrefix(path, "/api/badge/"),
path == "/api/metrics",
strings.HasPrefix(path, "/api/healthz"),
strings.HasSuffix(path, "/public"):
return true
}
return false
}
// requireAuth gates every /api route when the auth_enabled setting is on.
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if !s.authEnabled() || publicAPI(c.Request.URL.Path) {
c.Next()
return
}
if s.sessionUser(c) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
c.Next()
}
}
// ---------- handlers ----------
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
// sessionInfo reports auth state for the frontend shell — always public so
// the UI can decide between the board and the login screen.
func (s *Server) authSession(c *gin.Context) {
on := s.authEnabled()
out := gin.H{"enabled": on, "authed": false, "setup": false}
if !on {
c.JSON(http.StatusOK, out)
return
}
out["setup"] = s.userCount() == 0
if uid := s.sessionUser(c); uid != "" {
var name string
_ = s.db.QueryRow(`SELECT username FROM users WHERE id = ?`, uid).Scan(&name)
out["authed"] = true
out["username"] = name
}
c.JSON(http.StatusOK, out)
}
// setup creates the first user — only while auth is on and no users exist.
func (s *Server) authSetup(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
if s.userCount() > 0 {
fail(c, http.StatusConflict, "admin user already exists")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil ||
len(strings.TrimSpace(in.Username)) < 2 || len(in.Password) < 8 {
fail(c, http.StatusBadRequest, "username (2+) and password (8+) required")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
fail(c, http.StatusInternalServerError, "hash failed")
return
}
uid := newID("u")
if _, err := s.db.Exec(`INSERT INTO users (id, username, pass_hash) VALUES (?,?,?)`,
uid, strings.TrimSpace(in.Username), string(hash)); err != nil {
fail(c, http.StatusConflict, "username taken")
return
}
s.createSession(c, uid)
c.JSON(http.StatusCreated, gin.H{"username": in.Username})
}
func (s *Server) authLogin(c *gin.Context) {
if !s.authEnabled() {
fail(c, http.StatusBadRequest, "auth is not enabled")
return
}
ip := c.ClientIP()
if !loginAllowed(ip) {
fail(c, http.StatusTooManyRequests, "too many attempts — wait a minute")
return
}
var in credentials
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "invalid body")
return
}
var uid, hash string
err := s.db.QueryRow(`SELECT id, pass_hash FROM users WHERE username = ?`,
strings.TrimSpace(in.Username)).Scan(&uid, &hash)
if err != nil {
// Unknown user — run bcrypt anyway so timing doesn't leak existence.
hash = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5sA5t6p7K0dGm7ZC4yQ0yCk0cFk0K"
}
// CompareHashAndPassword is constant-time; err != nil covers both
// unknown user (dummy hash above) and wrong password.
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) != nil {
fail(c, http.StatusUnauthorized, "invalid credentials")
return
}
s.createSession(c, uid)
c.JSON(http.StatusOK, gin.H{"username": in.Username})
}
func (s *Server) authLogout(c *gin.Context) {
if ck, err := c.Cookie(sessionCookie); err == nil {
_, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, ck)
}
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
})
c.Status(http.StatusNoContent)
}
// authDisable turns auth off and wipes users+sessions. Requires a session —
// you can't switch the lock off from outside.
func (s *Server) authDisable(c *gin.Context) {
if s.sessionUser(c) == "" {
fail(c, http.StatusUnauthorized, "authentication required")
return
}
_, _ = s.db.Exec(`DELETE FROM sessions`)
_, _ = s.db.Exec(`DELETE FROM users`)
if _, err := s.db.Exec(`INSERT INTO settings (key, value) VALUES ('auth_enabled','false')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
s.log.Error("auth disable", zap.Error(err))
}
c.Status(http.StatusNoContent)
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/db"
"go.uber.org/zap"
)
// authServer returns the bare server (no httptest) so tests can plant
// sessions and inspect cookies directly.
func authServer(t *testing.T) *Server {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
gin.SetMode(gin.TestMode)
// Login rate limits are keyed by IP in a package-global map — reset so
// tests don't count each other's attempts.
loginMu.Lock()
loginHits = map[string][]time.Time{}
loginMu.Unlock()
return newServer(zap.NewNop(), d, t.TempDir())
}
func req(t *testing.T, s *Server, method, path, body, cookie string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
if cookie != "" {
r.Header.Set("Cookie", sessionCookie+"="+cookie)
}
s.routes().ServeHTTP(w, r)
return w
}
func sessionCookieOf(w *httptest.ResponseRecorder) string {
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookie {
return c.Value
}
}
return ""
}
func enableAuth(t *testing.T, s *Server) {
t.Helper()
if w := req(t, s, "PUT", "/api/settings", `{"auth_enabled":true}`, ""); w.Code != 200 {
t.Fatalf("enable auth: %d", w.Code)
}
}
func TestAuthDisabledByDefault(t *testing.T) {
s := authServer(t)
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
t.Fatalf("sections open when auth off: %d", w.Code)
}
var out map[string]any
w := req(t, s, "GET", "/api/auth/session", "", "")
_ = json.NewDecoder(w.Body).Decode(&out)
if out["enabled"] != false {
t.Fatalf("auth should report disabled: %v", out)
}
}
func TestAuthLifecycle(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
// Everything gates now.
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 401 {
t.Fatalf("expected 401, got %d", w.Code)
}
// Session endpoint stays public and reports setup mode.
var sess map[string]any
w := req(t, s, "GET", "/api/auth/session", "", "")
_ = json.NewDecoder(w.Body).Decode(&sess)
if sess["enabled"] != true || sess["setup"] != true || sess["authed"] != false {
t.Fatalf("bad session payload: %v", sess)
}
// Public surfaces stay open even gated.
for _, p := range []string{"/api/healthz", "/api/metrics", "/api/badge/uptime/x.svg"} {
if w := req(t, s, "GET", p, "", ""); w.Code == 401 {
t.Fatalf("public path %s gated", p)
}
}
// Setup creates the first admin and returns a session cookie.
w = req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
if w.Code != 201 {
t.Fatalf("setup: %d body=%s", w.Code, w.Body)
}
cookie := sessionCookieOf(w)
if cookie == "" {
t.Fatal("setup did not set a session cookie")
}
// Second setup blocked.
if w := req(t, s, "POST", "/api/auth/setup", `{"username":"x","password":"another-pass"}`, ""); w.Code != 409 {
t.Fatalf("second setup should 409, got %d", w.Code)
}
// Authed request with the cookie.
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 200 {
t.Fatalf("authed request: %d", w.Code)
}
// Logout kills the session.
req(t, s, "POST", "/api/auth/logout", "", cookie)
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("expected 401 after logout, got %d", w.Code)
}
// Login wrong password -> 401; right -> 200 + fresh cookie.
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"wrong-password"}`, ""); w.Code != 401 {
t.Fatalf("bad login should 401, got %d", w.Code)
}
w = req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
if w.Code != 200 {
t.Fatalf("login: %d", w.Code)
}
if sessionCookieOf(w) == "" {
t.Fatal("login set no cookie")
}
}
func TestAuthLoginRateLimit(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
req(t, s, "POST", "/api/auth/logout", "", "")
for i := 0; i < 5; i++ {
req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"nope-nope"}`, "")
}
if w := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, ""); w.Code != 429 {
t.Fatalf("6th login should be rate-limited, got %d", w.Code)
}
}
func TestAuthSessionExpiryAndRotation(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
cookie := sessionCookieOf(w)
// Expired session -> 401.
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("expired session should 401, got %d", w.Code)
}
// Near-expiry session rotates: response carries a new cookie.
fresh := req(t, s, "POST", "/api/auth/login", `{"username":"admin","password":"correct-horse"}`, "")
cookie = sessionCookieOf(fresh)
_, _ = s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token = ?`,
time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z"), cookie)
w = req(t, s, "GET", "/api/sections", "", cookie)
if w.Code != 200 {
t.Fatalf("rotating request should pass, got %d", w.Code)
}
newCookie := sessionCookieOf(w)
if newCookie == "" || newCookie == cookie {
t.Fatal("expected session rotation to issue a new token")
}
// Old token is dead.
if w := req(t, s, "GET", "/api/sections", "", cookie); w.Code != 401 {
t.Fatalf("old token should be invalid after rotation, got %d", w.Code)
}
}
func TestAuthDisable(t *testing.T) {
s := authServer(t)
enableAuth(t, s)
w := req(t, s, "POST", "/api/auth/setup", `{"username":"admin","password":"correct-horse"}`, "")
cookie := sessionCookieOf(w)
// Unauthenticated disable is refused.
if w := req(t, s, "POST", "/api/auth/disable", "", ""); w.Code != 401 {
t.Fatalf("disable without session should 401, got %d", w.Code)
}
if w := req(t, s, "POST", "/api/auth/disable", "", cookie); w.Code != 204 {
t.Fatalf("disable: %d", w.Code)
}
if w := req(t, s, "GET", "/api/sections", "", ""); w.Code != 200 {
t.Fatalf("sections should reopen after disable, got %d", w.Code)
}
}
+27 -11
View File
@@ -10,8 +10,16 @@ import (
// board loads the full tree: sections ordered, each with items and urls.
// Three flat queries assembled in memory — the whole board is ~100 rows.
func (s *Server) board() ([]Section, error) {
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`)
// boardID filters to one board; "" returns every section (export, legacy).
// COALESCE maps pre-boards rows onto the default 'b_home' board.
func (s *Server) board(boardID string) ([]Section, error) {
q := `SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections`
var args []any
if boardID != "" {
q += ` WHERE board_id = ?`
args = append(args, boardID)
}
secRows, err := s.db.Query(q+` ORDER BY position`, args...)
if err != nil {
return nil, err
}
@@ -21,7 +29,7 @@ func (s *Server) board() ([]Section, error) {
byID := map[string]*Section{}
for secRows.Next() {
var sec Section
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
return nil, err
}
sec.Items = []Item{}
@@ -89,7 +97,7 @@ func (s *Server) board() ([]Section, error) {
}
func (s *Server) listSections(c *gin.Context) {
sections, err := s.board()
sections, err := s.board(c.Query("board"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -100,18 +108,26 @@ func (s *Server) listSections(c *gin.Context) {
func (s *Server) createSection(c *gin.Context) {
var in struct {
Name string `json:"name"`
BoardID string `json:"boardId"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
boardID := in.BoardID
if boardID == "" {
boardID = "b_home"
}
var maxPos sql.NullFloat64
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil {
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections WHERE board_id = ?`, boardID).Scan(&maxPos); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}}
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil {
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), BoardID: boardID, Items: []Item{}}
// The subquery yields NULL for an unknown board id instead of an FK error;
// board() maps NULL back onto the default board.
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position, board_id)
VALUES (?, ?, ?, (SELECT id FROM boards WHERE id = ?))`, sec.ID, sec.Name, sec.Position, boardID); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
@@ -140,8 +156,8 @@ func (s *Server) updateSection(c *gin.Context) {
}
}
var sec Section
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed)
err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, c.Param("id")).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID)
if notFound(err) {
fail(c, http.StatusNotFound, "section not found")
return
@@ -192,8 +208,8 @@ func (s *Server) reorderSection(c *gin.Context) {
return
}
var sec Section
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
if err := s.db.QueryRow(`SELECT id, name, position, collapsed, COALESCE(board_id,'b_home') FROM sections WHERE id = ?`, in.ID).
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed, &sec.BoardID); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
+120
View File
@@ -0,0 +1,120 @@
package api
// Multiple boards — sections carry board_id; the board list is small and
// lives next to the section tree. Deleting a board cascades its sections.
import (
"database/sql"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// Board is a named dashboard page reachable at /b/<slug>.
type Board struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Position float64 `json:"position"`
}
func (s *Server) listBoards(c *gin.Context) {
rows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []Board{}
for rows.Next() {
var b Board
if err := rows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
out = append(out, b)
}
c.JSON(http.StatusOK, out)
}
func (s *Server) createBoard(c *gin.Context) {
var in struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || strings.TrimSpace(in.Name) == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
name := strings.TrimSpace(in.Name)
slug := slugify(name)
if slug == "" {
slug = "board"
}
// Uniquify: 'media', 'media-2', 'media-3'…
base := slug
for i := 2; ; i++ {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards WHERE slug = ?`, slug).Scan(&n)
if n == 0 {
break
}
slug = base + "-" + strconv.Itoa(i)
}
var maxPos sql.NullFloat64
_ = s.db.QueryRow(`SELECT MAX(position) FROM boards`).Scan(&maxPos)
b := Board{ID: newID("b"), Slug: slug, Name: name, Position: midpoint(ptrOr(maxPos), nil)}
if _, err := s.db.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?,?,?,?)`,
b.ID, b.Slug, b.Name, b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusCreated, b)
}
func (s *Server) updateBoard(c *gin.Context) {
var in struct {
Name *string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Name == nil || strings.TrimSpace(*in.Name) == "" {
fail(c, http.StatusBadRequest, "name required")
return
}
// Slug stays fixed on rename — links shouldn't break.
res, err := s.db.Exec(`UPDATE boards SET name = ? WHERE id = ?`, strings.TrimSpace(*in.Name), c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "board not found")
return
}
var b Board
if err := s.db.QueryRow(`SELECT id, slug, name, position FROM boards WHERE id = ?`, c.Param("id")).
Scan(&b.ID, &b.Slug, &b.Name, &b.Position); err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, b)
}
func (s *Server) deleteBoard(c *gin.Context) {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM boards`).Scan(&n)
if n <= 1 {
fail(c, http.StatusBadRequest, "cannot delete the last board")
return
}
res, err := s.db.Exec(`DELETE FROM boards WHERE id = ?`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
if n, _ := res.RowsAffected(); n == 0 {
fail(c, http.StatusNotFound, "board not found")
return
}
c.Status(http.StatusNoContent)
}
+38 -4
View File
@@ -27,7 +27,7 @@ const domainCols = `id, name, tld, active, auto_renew, alert_days_before, tags,
ssl_key_size, ssl_sig_algo, ssl_alt_names, host_country, host_country_code,
host_region, host_city, host_isp, host_org, host_as, host_lat, host_lon,
dns_provider, email_provider, hosting_provider, ca_provider, headers,
favicon_url, lookup_error, interval_h, last_checked, position, created_at`
favicon_url, lookup_error, interval_h, last_checked, position, alerts, created_at`
// domainRow mirrors the domains table; JSON text columns decode on scan.
type domainRow struct {
@@ -87,12 +87,13 @@ type domainRow struct {
IntervalH int `json:"intervalH"`
LastChecked *string `json:"lastChecked"`
Position float64 `json:"position"`
Alerts json.RawMessage `json:"alerts"`
CreatedAt string `json:"createdAt"`
}
func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
var d domainRow
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers string
var tags, statuses, ns, mx, txt, v4, v6, altNames, headers, alerts string
var active, autoRenew, privacy, lock int
err := row.Scan(&d.ID, &d.Name, &d.TLD, &active, &autoRenew, &d.AlertDaysBefore,
&tags, &d.Notes, &d.ExpiryDate, &d.CreationDate, &d.UpdatedDate,
@@ -104,10 +105,11 @@ func scanDomain(row interface{ Scan(...any) error }) (*domainRow, error) {
&d.HostRegion, &d.HostCity, &d.HostISP, &d.HostOrg, &d.HostAS, &d.HostLat,
&d.HostLon, &d.DNSProvider, &d.EmailProvider, &d.HostingProvider, &d.CAProvider,
&headers, &d.FaviconURL, &d.LookupError, &d.IntervalH, &d.LastChecked,
&d.Position, &d.CreatedAt)
&d.Position, &alerts, &d.CreatedAt)
if err != nil {
return nil, err
}
d.Alerts = json.RawMessage(alerts)
d.Active = active != 0
d.AutoRenew = autoRenew != 0
d.PrivacyEnabled = privacy != 0
@@ -200,6 +202,7 @@ type domainInput struct {
IntervalH *int `json:"intervalH"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
}
func (s *Server) createDomain(c *gin.Context) {
@@ -218,6 +221,13 @@ func (s *Server) createDomain(c *gin.Context) {
if in.Tags == nil {
tags = []byte("[]")
}
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
}
_, err := s.db.Exec(`INSERT INTO domains
(id, name, tld, active, auto_renew, alert_days_before, tags, notes, interval_h, position)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
@@ -228,6 +238,9 @@ func (s *Server) createDomain(c *gin.Context) {
fail(c, http.StatusConflict, "domain already tracked or insert failed")
return
}
if in.Alerts != nil {
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
// First lookup runs inline so the response carries real data.
s.refreshDomain(id)
d, err := s.loadDomain(id)
@@ -298,6 +311,14 @@ func (s *Server) patchDomain(c *gin.Context) {
tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE domains SET tags = ? WHERE id = ?`, string(tags), id)
}
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
_, _ = s.db.Exec(`UPDATE domains SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
d, err := s.loadDomain(id)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
@@ -338,6 +359,10 @@ func (s *Server) refreshDomain(id string) {
return
}
nv := viewOf(next)
rules := parseAlertRules(next.Alerts)
if rules.Mute {
return
}
switch {
case nv.DaysUntilExpiry != nil && *nv.DaysUntilExpiry < 0 && !(prev.DaysUntilExpiry != nil && *prev.DaysUntilExpiry < 0):
s.notify("domain.expired", next.Name, fmt.Sprintf("Domain %s has expired", next.Name))
@@ -345,10 +370,19 @@ func (s *Server) refreshDomain(id string) {
s.notify("domain.expiring", next.Name,
fmt.Sprintf("Domain %s expires in %d days", next.Name, *nv.DaysUntilExpiry))
}
if nv.SSLExpiring && !prev.SSLExpiring && nv.SSLDaysUntilExpiry != nil {
// TLS threshold: alerts.certDays overrides alertDaysBefore.
certDays := next.AlertDaysBefore
if rules.CertDays > 0 {
certDays = rules.CertDays
}
sslNow := nv.SSLDaysUntilExpiry != nil && *nv.SSLDaysUntilExpiry <= certDays
sslPrev := prev.SSLDaysUntilExpiry != nil && *prev.SSLDaysUntilExpiry <= certDays
if sslNow && !sslPrev {
s.notify("domain.sslExpiring", next.Name,
fmt.Sprintf("TLS certificate for %s expires in %d days", next.Name, *nv.SSLDaysUntilExpiry))
}
// Subdomain discovery rides the refresh schedule, gated to once/day.
s.maybeSweepSubdomains(next)
}
func (s *Server) refreshDomainH(c *gin.Context) {
+2 -2
View File
@@ -30,11 +30,11 @@ func (s *Server) importExternal(c *gin.Context) {
fail(c, http.StatusBadRequest, err.Error())
return
}
if err := s.replaceBoard(sections, nil); err != nil {
if err := s.replaceBoard(sections, nil, nil); err != nil {
failImport(c, err)
return
}
board, err := s.board()
board, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
+44 -6
View File
@@ -20,21 +20,22 @@ import (
const monitorCols = `id, name, type, url, hostname, port, method, headers, body,
keyword, keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
retries, active, status, push_token, tags, notes, position, last_check, created_at`
retries, active, status, push_token, tags, notes, alerts, position, last_check, created_at`
func scanMonitor(row interface{ Scan(...any) error }) (*monitor.Monitor, error) {
var m monitor.Monitor
var tags string
var tags, alerts string
var invert, active int
err := row.Scan(&m.ID, &m.Name, &m.Type, &m.URL, &m.Hostname, &m.Port, &m.Method,
&m.Headers, &m.Body, &m.Keyword, &invert, &m.JSONQuery, &m.Expected, &m.DNSType,
&m.IntervalS, &m.TimeoutS, &m.Retries, &active, &m.Status, &m.PushToken,
&tags, &m.Notes, &m.Position, &m.LastCheck, &m.CreatedAt)
&tags, &m.Notes, &alerts, &m.Position, &m.LastCheck, &m.CreatedAt)
if err != nil {
return nil, err
}
m.KeywordInvert = invert != 0
m.Active = active != 0
m.Alerts = json.RawMessage(alerts)
_ = json.Unmarshal([]byte(tags), &m.Tags)
if m.Tags == nil {
m.Tags = []string{}
@@ -142,6 +143,7 @@ type monitorInput struct {
Active *bool `json:"active"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
}
func validMonitorType(t string) bool {
@@ -209,17 +211,26 @@ func (s *Server) createMonitor(c *gin.Context) {
if in.Tags == nil {
tags = []byte("[]")
}
alerts := "{}"
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
alerts = string(in.Alerts)
}
_, err := s.db.Exec(`INSERT INTO monitors
(id, name, type, url, hostname, port, method, headers, body, keyword,
keyword_invert, json_query, expected, dns_type, interval_s, timeout_s,
retries, active, push_token, tags, notes, position)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
retries, active, push_token, tags, notes, alerts, position)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, *in.Name, *in.Type, strOr(in.URL), strOr(in.Hostname), intOr(in.Port),
strOr2(in.Method, "GET"), strOr2(in.Headers, "{}"), strOr(in.Body),
strOr(in.Keyword), boolOr(in.KeywordInvert), strOr(in.JSONQuery),
strOr(in.Expected), strOr2(in.DNSType, "A"), intOr2(in.IntervalS, 60),
intOr2(in.TimeoutS, 10), intOr(in.Retries), boolOr2(in.Active, true),
token, string(tags), strOr(in.Notes), float64(time.Now().UnixNano())/1e9)
token, string(tags), strOr(in.Notes), alerts, float64(time.Now().UnixNano())/1e9)
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -294,6 +305,14 @@ func (s *Server) patchMonitor(c *gin.Context) {
tags, _ := json.Marshal(in.Tags)
_, _ = s.db.Exec(`UPDATE monitors SET tags = ? WHERE id = ?`, string(tags), id)
}
if in.Alerts != nil {
var rules alertRules
if err := json.Unmarshal(in.Alerts, &rules); err != nil {
fail(c, http.StatusBadRequest, "alerts must be an object")
return
}
_, _ = s.db.Exec(`UPDATE monitors SET alerts = ? WHERE id = ?`, string(in.Alerts), id)
}
if in.Active != nil {
// pause/resume flips the stored status back to pending for a fresh probe
status := "pending"
@@ -412,6 +431,9 @@ func (s *Server) runScheduler(done <-chan struct{}) {
s.log.Error("heartbeat prune", zap.Error(err))
}
// System samples are high-rate; a week of raw ticks is enough.
if _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ','now')`); err != nil {
s.log.Error("session prune", zap.Error(err))
}
if _, err := s.db.Exec(`DELETE FROM system_stats WHERE ts < datetime('now','-7 days')`); err != nil {
s.log.Error("system stats prune", zap.Error(err))
}
@@ -473,8 +495,13 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
s.writeHeartbeat(m, res)
prev := m.Status
next := "down"
rules := parseAlertRules(m.Alerts)
if res.Up {
next = "up"
} else if n := rules.ConsecutiveFailures; n > 1 && prev != "down" &&
s.consecutiveDowns(m.ID, n) < n {
// Flap damping: hold the previous status until N checks in a row fail.
next = prev
}
if _, err := s.db.Exec(`UPDATE monitors SET status = ?, last_check = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
next, m.ID); err != nil {
@@ -483,6 +510,14 @@ func (s *Server) runCheck(m *monitor.Monitor) *monitor.Result {
if prev != next && prev != "pending" {
s.onMonitorTransition(m, prev, next)
}
// Latency warn: notify once when ping crosses above the threshold.
if rules.LatencyWarnMs > 0 && res.Up && res.PingMs >= rules.LatencyWarnMs &&
!rules.Mute && prev != "pending" && !s.inMaintenance(m.ID) {
if p := s.prevPing(m.ID); p >= 0 && p < rules.LatencyWarnMs {
s.notify("monitor.slow", m.Name,
fmt.Sprintf("Monitor %q latency %dms (threshold %dms)", m.Name, res.PingMs, rules.LatencyWarnMs))
}
}
return res
}
@@ -507,6 +542,9 @@ func (s *Server) onMonitorTransition(m *monitor.Monitor, prev, next string) {
return
}
s.autoIncident(m.ID, m.Name, next)
if parseAlertRules(m.Alerts).Mute {
return
}
target := m.URL
if target == "" {
target = m.Hostname
+242 -43
View File
@@ -3,19 +3,29 @@ package api
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"net/smtp"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// Notifications: one outbound webhook, configured via the notify_webhook
// settings key. Payload carries event/name/text/content/message so the same
// POST satisfies Slack ({text}), Discord ({content}), ntfy, and generic
// receivers. SMTP and per-service channels are deliberately out of scope —
// a webhook bridge (e.g. ntfy, Gotify, shoutrrr) covers them.
// Notifications: a small transport registry. Each transport is configured via
// the settings key notify_<name> holding a JSON object of fields; a transport
// is active when its required fields are set. Multiple transports may be
// active — notify() fans out to all of them.
//
// notify_webhook keeps back-compat: a plain string value means {"url": value}.
//
// Secrets (smtp_pass, tokens) live in settings like any other value and are
// never logged. Single-user self-hosted is the threat model; P5d auth gates
// the settings endpoint when enabled.
type notifyPayload struct {
Event string `json:"event"`
@@ -26,54 +36,213 @@ type notifyPayload struct {
At string `json:"at"`
}
func (s *Server) webhookURL() string {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_webhook'`).Scan(&raw); err != nil {
return ""
}
// Settings values are stored as JSON — unwrap the quoted string.
var u string
if err := json.Unmarshal([]byte(raw), &u); err != nil {
return ""
}
return u
type transport struct {
name string
required []string
send func(ctx context.Context, cfg map[string]string, p notifyPayload) error
}
// notify fires the webhook asynchronously; delivery failures are logged, never fatal.
var transports = []transport{
{"webhook", []string{"url"}, sendWebhook},
{"slack", []string{"url"}, sendSlack},
{"discord", []string{"url"}, sendDiscord},
{"telegram", []string{"token", "chat_id"}, sendTelegram},
{"gotify", []string{"server", "token"}, sendGotify},
{"ntfy", []string{"server", "topic"}, sendNtfy},
{"smtp", []string{"host", "port", "from", "to"}, sendSMTP},
}
// notifyCfg loads notify_<name> from settings. Values are JSON: an object for
// presets, or a bare string (legacy webhook) meaning {"url": s}.
func (s *Server) notifyCfg(name string) map[string]string {
var raw string
if err := s.db.QueryRow(`SELECT value FROM settings WHERE key = 'notify_`+name+`'`).Scan(&raw); err != nil {
return nil
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err != nil {
var str string
if err := json.Unmarshal([]byte(raw), &str); err == nil && name == "webhook" {
return map[string]string{"url": str}
}
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
if str, ok := v.(string); ok {
out[k] = str
}
}
return out
}
// configured reports whether all required fields are present. Partially
// configured transports count as configured so their send error surfaces.
func (t transport) configured(cfg map[string]string) bool {
if cfg == nil {
return false
}
for _, k := range t.required {
if strings.TrimSpace(cfg[k]) == "" {
return false
}
}
return true
}
// notify fans an event out to every configured transport, asynchronously.
func (s *Server) notify(event, name, msg string) {
url := s.webhookURL()
if url == "" {
return
}
if u := parseHTTPURL(url); u == "" {
s.log.Warn("notify_webhook is not an http(s) URL", zap.String("event", event))
return
}
p := notifyPayload{Event: event, Name: name, Text: msg, Content: msg, Message: msg,
At: time.Now().UTC().Format(time.RFC3339)}
go s.deliver(url, p)
for _, tr := range transports {
cfg := s.notifyCfg(tr.name)
if !tr.configured(cfg) {
continue
}
tr, cfg := tr, cfg
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := tr.send(ctx, cfg, p); err != nil {
s.log.Warn("notify failed", zap.String("transport", tr.name),
zap.String("event", event), zap.Error(err))
}
}()
}
}
func (s *Server) deliver(url string, p notifyPayload) {
body, _ := json.Marshal(p)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// postJSON posts a JSON body and reports non-2xx as an error.
func postJSON(ctx context.Context, url string, payload any, headers map[string]string) error {
if parseHTTPURL(url) == "" {
return fmt.Errorf("not an http(s) URL")
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Title", p.Text) // ntfy reads the title header
req.Header.Set("X-Dash-Event", p.Event)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
s.log.Warn("webhook delivery failed", zap.String("event", p.Event), zap.Error(err))
return
return err
}
resp.Body.Close()
if resp.StatusCode >= 400 {
s.log.Warn("webhook rejected", zap.String("event", p.Event), zap.Int("status", resp.StatusCode))
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("returned %d", resp.StatusCode)
}
return nil
}
func sendWebhook(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], p, map[string]string{
"Title": p.Text, "X-Dash-Event": p.Event,
})
}
func sendSlack(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], map[string]any{"text": p.Text}, nil)
}
func sendDiscord(ctx context.Context, cfg map[string]string, p notifyPayload) error {
return postJSON(ctx, cfg["url"], map[string]any{"content": p.Content}, nil)
}
func sendTelegram(ctx context.Context, cfg map[string]string, p notifyPayload) error {
base := strings.TrimRight(cfg["server"], "/") // optional self-hosted bot-api
if base == "" {
base = "https://api.telegram.org"
}
url := base + "/bot" + cfg["token"] + "/sendMessage"
return postJSON(ctx, url, map[string]any{
"chat_id": cfg["chat_id"], "text": p.Text,
}, nil)
}
func sendGotify(ctx context.Context, cfg map[string]string, p notifyPayload) error {
server := strings.TrimRight(cfg["server"], "/")
url := server + "/message?token=" + cfg["token"]
return postJSON(ctx, url, map[string]any{
"title": p.Name, "message": p.Message, "priority": 5,
}, nil)
}
func sendNtfy(ctx context.Context, cfg map[string]string, p notifyPayload) error {
server := strings.TrimRight(cfg["server"], "/")
url := server + "/" + strings.TrimLeft(cfg["topic"], "/")
if parseHTTPURL(url) == "" {
return fmt.Errorf("not an http(s) URL")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(p.Message))
if err != nil {
return err
}
req.Header.Set("Title", p.Name)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("returned %d", resp.StatusCode)
}
return nil
}
// sendSMTP uses net/smtp with a hard deadline, opportunistic STARTTLS, and
// plain auth only when a user is configured.
func sendSMTP(ctx context.Context, cfg map[string]string, p notifyPayload) error {
host, port := cfg["host"], cfg["port"]
addr := net.JoinHostPort(host, port)
d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return err
}
defer conn.Close()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return err
}
}
if cfg["user"] != "" {
if err := c.Auth(smtp.PlainAuth("", cfg["user"], cfg["pass"], host)); err != nil {
return err
}
}
if err := c.Mail(cfg["from"]); err != nil {
return err
}
for _, rcpt := range strings.Split(cfg["to"], ",") {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: [Dash] %s\r\n\r\n%s\r\n",
cfg["from"], cfg["to"], p.Text, p.Message)
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
// parseHTTPURL enforces http/https on the configured webhook — same SSRF
@@ -85,12 +254,42 @@ func parseHTTPURL(raw string) string {
return raw
}
// notifyTest lets the settings UI verify the configured webhook.
// notifyTest sends a test event synchronously so the UI gets a real result.
// Optional body {"transport": "smtp"} limits the test to one transport.
func (s *Server) notifyTest(c *gin.Context) {
if s.webhookURL() == "" {
fail(c, http.StatusBadRequest, "notify_webhook not configured")
var in struct {
Transport string `json:"transport"`
}
_ = c.ShouldBindJSON(&in)
p := notifyPayload{Event: "test", Name: "dash", Text: "Dash test notification",
Content: "Dash test notification", Message: "Dash test notification",
At: time.Now().UTC().Format(time.RFC3339)}
results := map[string]string{}
for _, tr := range transports {
if in.Transport != "" && tr.name != in.Transport {
continue
}
cfg := s.notifyCfg(tr.name)
if !tr.configured(cfg) {
if in.Transport != "" {
fail(c, http.StatusBadRequest, tr.name+" not configured")
return
}
s.notify("test", "dash", "Dash test notification")
c.JSON(http.StatusOK, gin.H{"status": "sent"})
continue
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
err := tr.send(ctx, cfg, p)
cancel()
if err != nil {
results[tr.name] = "error: " + err.Error()
} else {
results[tr.name] = "ok"
}
}
if len(results) == 0 {
fail(c, http.StatusBadRequest, "no notification transport configured")
return
}
c.JSON(http.StatusOK, gin.H{"results": results})
}
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/tdvorak/dash/internal/domain"
"go.uber.org/zap"
)
// Subdomain discovery: crt.sh + DNS, persisted per domain and swept at most
// once a day inside the domain refresh. CT names are untrusted strings —
// stored as text, rendered as text, never linked.
type subdomainView struct {
Name string `json:"name"`
IPs []string `json:"ips"`
FirstSeen string `json:"firstSeen"`
LastSeen string `json:"lastSeen"`
}
func (s *Server) listSubdomains(c *gin.Context) {
rows, err := s.db.Query(`SELECT name, ips, first_seen, last_seen
FROM subdomains WHERE domain_id = ? ORDER BY name`, c.Param("id"))
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
out := []subdomainView{}
for rows.Next() {
var v subdomainView
var ips string
if err := rows.Scan(&v.Name, &ips, &v.FirstSeen, &v.LastSeen); err != nil {
continue
}
_ = json.Unmarshal([]byte(ips), &v.IPs)
if v.IPs == nil {
v.IPs = []string{}
}
out = append(out, v)
}
c.JSON(http.StatusOK, out)
}
// refreshSubdomains forces a discovery run for one domain.
func (s *Server) refreshSubdomains(c *gin.Context) {
id := c.Param("id")
d, err := s.loadDomain(id)
if notFound(err) {
fail(c, http.StatusNotFound, "domain not found")
return
}
s.sweepSubdomains(d)
s.listSubdomains(c)
}
// sweepSubdomains runs CT+DNS discovery and upserts rows. Called from
// refreshDomain when sub_checked_at is stale (>24h), and on manual refresh.
func (s *Server) sweepSubdomains(d *domainRow) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
found, err := domain.DiscoverSubdomains(ctx, d.Name)
if err != nil {
s.log.Warn("subdomain discovery", zap.String("domain", d.Name), zap.Error(err))
return
}
for _, f := range found {
ips, _ := json.Marshal(f.IPs)
if _, err := s.db.Exec(`INSERT INTO subdomains (id, domain_id, name, ips)
VALUES (?,?,?,?)
ON CONFLICT(domain_id, name) DO UPDATE SET ips = excluded.ips,
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
newID("sub"), d.ID, f.Name, string(ips)); err != nil {
s.log.Error("subdomain upsert", zap.Error(err))
}
}
_, _ = s.db.Exec(`UPDATE domains SET sub_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, d.ID)
}
// maybeSweepSubdomains gates discovery to once per day per domain.
func (s *Server) maybeSweepSubdomains(d *domainRow) {
var checked *string
_ = s.db.QueryRow(`SELECT sub_checked_at FROM domains WHERE id = ?`, d.ID).Scan(&checked)
if checked != nil && *checked != "" {
if t, err := time.Parse("2006-01-02T15:04:05.000Z", *checked); err == nil &&
time.Since(t) < 24*time.Hour {
return
}
}
s.sweepSubdomains(d)
}
+35 -2
View File
@@ -41,13 +41,46 @@ type StatSample struct {
Load15 float64 `json:"load15"`
Temps map[string]float64 `json:"temps"`
Containers []ContainerStat `json:"containers"`
// Optional collectors — present only when the host has the tools.
Smart []SmartDisk `json:"smart,omitempty"`
ZFS []ZFSPool `json:"zfs,omitempty"`
GPU []GPUStat `json:"gpu,omitempty"`
}
// ContainerStat is one Docker container's liveness; per-container CPU/mem
// needs cgroup sampling — deferred (v1 ships name+state only).
// ContainerStat is one Docker container's liveness plus resource use.
// CPU is percent of one core (may exceed 100); Mem* are bytes. Zero when the
// agent couldn't reach the stats endpoint.
type ContainerStat struct {
Name string `json:"name"`
State string `json:"state"`
CPU float64 `json:"cpu,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemLimit float64 `json:"memLimit,omitempty"`
}
// SmartDisk is one drive's SMART summary (smartctl).
type SmartDisk struct {
Device string `json:"device"`
Model string `json:"model"`
Passed *bool `json:"passed,omitempty"`
TempC float64 `json:"tempC,omitempty"`
}
// ZFSPool is one pool's health and space (zpool).
type ZFSPool struct {
Name string `json:"name"`
Health string `json:"health"`
Size float64 `json:"size"`
Free float64 `json:"free"`
}
// GPUStat is one GPU's basic metrics (sysfs or nvidia-smi).
type GPUStat struct {
Name string `json:"name"`
TempC float64 `json:"tempC,omitempty"`
UtilPct float64 `json:"utilPct,omitempty"`
MemUsed float64 `json:"memUsed,omitempty"`
MemTotal float64 `json:"memTotal,omitempty"`
}
// System is one monitored host; `latest` is the newest StatSample.
+42 -7
View File
@@ -28,6 +28,7 @@ type Export struct {
Version int `json:"version"`
Settings map[string]json.RawMessage `json:"settings"`
Sections []Section `json:"sections"`
Boards []Board `json:"boards,omitempty"`
Monitors []json.RawMessage `json:"monitors,omitempty"`
Domains []json.RawMessage `json:"domains,omitempty"`
Systems []json.RawMessage `json:"systems,omitempty"`
@@ -74,12 +75,21 @@ func (s *Server) exportBoard(c *gin.Context) {
fail(c, http.StatusInternalServerError, err.Error())
return
}
sections, err := s.board()
sections, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
}
ex := Export{Version: 2, Settings: settings, Sections: sections}
if bRows, err := s.db.Query(`SELECT id, slug, name, position FROM boards ORDER BY position`); err == nil {
for bRows.Next() {
var b Board
if bRows.Scan(&b.ID, &b.Slug, &b.Name, &b.Position) == nil {
ex.Boards = append(ex.Boards, b)
}
}
bRows.Close()
}
for _, t := range []struct {
name string
dst *[]json.RawMessage
@@ -103,11 +113,11 @@ func (s *Server) importBoard(c *gin.Context) {
fail(c, http.StatusBadRequest, "invalid export payload")
return
}
if err := s.replaceBoard(in.Sections, in.Settings); err != nil {
if err := s.replaceBoard(in.Sections, in.Boards, in.Settings); err != nil {
failImport(c, err)
return
}
sections, err := s.board()
sections, err := s.board("")
if err != nil {
fail(c, http.StatusInternalServerError, err.Error())
return
@@ -116,8 +126,10 @@ func (s *Server) importBoard(c *gin.Context) {
}
// replaceBoard swaps all board rows inside one transaction — shared by the
// Dash export import and the external-config importers.
func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMessage) error {
// Dash export import and the external-config importers. A payload carrying
// boards replaces the board list too; one without (v1/v2 exports, foreign
// imports) leaves boards alone and sections land on the default board.
func (s *Server) replaceBoard(sections []Section, boards []Board, settings map[string]json.RawMessage) error {
tx, err := s.db.Begin()
if err != nil {
return err
@@ -129,12 +141,35 @@ func (s *Server) replaceBoard(sections []Section, settings map[string]json.RawMe
return err
}
}
if len(boards) > 0 {
if _, err := tx.Exec(`DELETE FROM boards`); err != nil {
return err
}
for _, b := range boards {
id, slug := b.ID, b.Slug
if slug == "" {
slug = slugify(b.Name)
}
if id == "" || slug == "" {
return fmt.Errorf("%w: board missing id or slug", errBadInput)
}
if _, err := tx.Exec(`INSERT INTO boards (id, slug, name, position) VALUES (?, ?, ?, ?)`,
id, slug, orDefault(b.Name, slug), b.Position); err != nil {
return err
}
}
// The default board must survive import — orphaned sections map to it.
if _, err := tx.Exec(`INSERT OR IGNORE INTO boards (id, slug, name, position) VALUES ('b_home','home','Home',0)`); err != nil {
return err
}
}
for _, sec := range sections {
if sec.ID == "" || sec.Name == "" {
return fmt.Errorf("%w: section missing id or name", errBadInput)
}
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed, board_id)
VALUES (?, ?, ?, ?, (SELECT id FROM boards WHERE id = ?))`,
sec.ID, sec.Name, sec.Position, sec.Collapsed, sec.BoardID); err != nil {
return err
}
for _, it := range sec.Items {
@@ -0,0 +1,10 @@
-- +goose Up
-- Per-target alert rules as a JSON blob: {consecutiveFailures, latencyWarnMs,
-- certDays, mute}. '{}' = defaults (alert on first failure, no latency rule,
-- cert threshold = alert_days_before, unmuted).
ALTER TABLE monitors ADD COLUMN alerts JSON NOT NULL DEFAULT '{}';
ALTER TABLE domains ADD COLUMN alerts JSON NOT NULL DEFAULT '{}';
-- +goose Down
ALTER TABLE monitors DROP COLUMN alerts;
ALTER TABLE domains DROP COLUMN alerts;
@@ -0,0 +1,18 @@
-- +goose Up
CREATE TABLE subdomains (
id TEXT PRIMARY KEY,
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
name TEXT NOT NULL,
ips JSON NOT NULL DEFAULT '[]',
first_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
last_seen TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(domain_id, name)
);
CREATE INDEX subdomains_domain_idx ON subdomains(domain_id);
-- Discovery runs at most once per day per domain, inside the domain sweep.
ALTER TABLE domains ADD COLUMN sub_checked_at TEXT;
-- +goose Down
ALTER TABLE domains DROP COLUMN sub_checked_at;
DROP TABLE IF EXISTS subdomains;
@@ -0,0 +1,22 @@
-- +goose Up
-- Opt-in local auth. Empty users table = setup mode; auth_enabled settings
-- key gates the middleware. Sessions live in SQLite — restart-safe, and a
-- `dash-agent`-style bearer token path is unaffected.
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
pass_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT NOT NULL
);
CREATE INDEX sessions_expiry_idx ON sessions(expires_at);
-- +goose Down
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,32 @@
-- +goose Up
-- Multiple boards. Every section belongs to exactly one board; the seeded
-- 'home' board adopts existing rows. NULL board_id maps to 'home' at read
-- time so old imports/exports keep working without a backfill.
CREATE TABLE boards (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
position REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT INTO boards (id, slug, name, position) VALUES ('b_home', 'home', 'Home', 1024);
ALTER TABLE sections ADD COLUMN board_id TEXT REFERENCES boards(id) ON DELETE CASCADE;
UPDATE sections SET board_id = 'b_home';
CREATE INDEX sections_board_pos ON sections(board_id, position);
-- +goose Down
DROP INDEX IF EXISTS sections_board_pos;
-- SQLite can't drop columns pre-3.35; rebuild instead.
CREATE TABLE sections_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
position REAL NOT NULL,
collapsed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
INSERT INTO sections_new (id, name, position, collapsed, created_at)
SELECT id, name, position, collapsed, created_at FROM sections;
DROP TABLE sections;
ALTER TABLE sections_new RENAME TO sections;
DROP TABLE boards;
+128
View File
@@ -0,0 +1,128 @@
package domain
// Subdomain discovery: certificate-transparency names from crt.sh, filtered
// to the owned suffix, then resolved via A/AAAA. Both network steps are
// injected seams so tests never leave the process.
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
// Subdomain is one discovered hostname and its resolved addresses.
type Subdomain struct {
Name string `json:"name"`
IPs []string `json:"ips"`
}
// CTNames returns candidate hostnames for domain from crt.sh.
// Seam: tests substitute a stub.
var CTNames = func(ctx context.Context, domain string) ([]string, error) {
url := "https://crt.sh/?q=%25." + domain + "&output=json"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
return nil, fmt.Errorf("crt.sh unreachable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("crt.sh returned %d", resp.StatusCode)
}
// Bound the response — popular domains can return megabytes.
var rows []struct {
NameValue string `json:"name_value"`
CommonName string `json:"common_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(&rows); err != nil {
return nil, fmt.Errorf("crt.sh decode failed")
}
var out []string
for _, r := range rows {
out = append(out, r.NameValue, r.CommonName)
}
return out, nil
}
// ResolveIPs returns A/AAAA addresses for a hostname. Seam: tests stub it.
var ResolveIPs = func(ctx context.Context, name string) ([]string, error) {
addrs, err := net.DefaultResolver.LookupIP(ctx, "ip", name)
if err != nil {
return nil, err
}
out := make([]string, 0, len(addrs))
for _, a := range addrs {
out = append(out, a.String())
}
return out, nil
}
// FilterCTNames reduces raw cert names to owned, canonical hostnames:
// lowercase, wildcard prefix stripped, must equal domain or end in .domain.
// CT names are untrusted text — stored and rendered as text only.
func FilterCTNames(raw []string, domain string) []string {
suffix := "." + domain
seen := map[string]bool{}
out := make([]string, 0, len(raw))
for _, r := range raw {
for _, name := range strings.Split(r, "\n") {
name = strings.ToLower(strings.TrimSpace(name))
name = strings.TrimPrefix(name, "*.")
if name == "" || (!strings.HasSuffix(name, suffix) && name != domain) {
continue
}
if strings.ContainsAny(name, " \t/\\\"'<>@") {
continue // cert names are untrusted; drop malformed entries
}
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
}
return out
}
// DiscoverSubdomains queries CT and resolves each surviving name, bounded:
// at most 500 candidates considered, 100 resolved, 8 concurrent lookups.
func DiscoverSubdomains(ctx context.Context, domain string) ([]Subdomain, error) {
raw, err := CTNames(ctx, domain)
if err != nil {
return nil, err
}
names := FilterCTNames(raw, domain)
if len(names) > 500 {
names = names[:500]
}
if len(names) > 100 {
names = names[:100]
}
out := make([]Subdomain, len(names))
var wg sync.WaitGroup
sem := make(chan struct{}, 8)
for i, name := range names {
out[i].Name = name
wg.Add(1)
sem <- struct{}{}
go func(i int, name string) {
defer wg.Done()
defer func() { <-sem }()
rctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if ips, err := ResolveIPs(rctx, name); err == nil {
out[i].IPs = ips
}
}(i, name)
}
wg.Wait()
return out, nil
}
@@ -0,0 +1,29 @@
package domain
import "testing"
func TestFilterCTNames(t *testing.T) {
raw := []string{
"api.example.com\nwww.example.com",
"*.example.com",
"example.com",
"other.com",
"api.example.com", // dup
"sub.api.example.com",
"evil name.example.com",
"example.com.evil.com",
}
got := FilterCTNames(raw, "example.com")
want := map[string]bool{
"api.example.com": true, "www.example.com": true,
"example.com": true, "sub.api.example.com": true,
}
if len(got) != len(want) {
t.Fatalf("got %v", got)
}
for _, n := range got {
if !want[n] {
t.Fatalf("unexpected name %q", n)
}
}
}
+1
View File
@@ -42,6 +42,7 @@ type Monitor struct {
PushToken string `json:"pushToken,omitempty"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
Alerts json.RawMessage `json:"alerts"`
Position float64 `json:"position"`
LastCheck *string `json:"lastCheck"`
CreatedAt string `json:"createdAt"`
View File
Whitespace-only changes.
+30
View File
@@ -0,0 +1,30 @@
package widget
import (
"context"
"encoding/json"
"fmt"
)
// Embed renders a sandboxed iframe client-side — registered only so
// /api/widgets/types lists it. The browser fetches the framed page, not the
// server (remote content may be LAN-only or need the user's cookies).
type embed struct{}
func init() { Register(embed{}) }
func (embed) Meta() Type {
return Type{
Type: "embed",
Name: "Embed",
Description: "Embed any page in a tile",
Local: true,
Fields: []Field{
{Key: "url", Label: "URL", Placeholder: "https://…", Required: true},
},
}
}
func (embed) Fetch(context.Context, json.RawMessage) (any, error) {
return nil, fmt.Errorf("embed renders client-side")
}
+89
View File
@@ -0,0 +1,89 @@
package widget
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// JSON status: GET a URL, extract one value by dot/bracket path
// ("data.status", "items[0].name"), render it in a tile. Covers the
// "is API X alive / what does it report" case without per-service widgets.
type jsonpath struct{}
func init() { Register(jsonpath{}) }
func (jsonpath) Meta() Type {
return Type{
Type: "jsonpath",
Name: "JSON value",
Description: "Extract one value from a JSON endpoint",
Fields: []Field{
{Key: "url", Label: "URL", Placeholder: "https://api…/status", Required: true},
{Key: "path", Label: "Path", Placeholder: "data.status", Required: true},
{Key: "header", Label: "Header", Placeholder: "Authorization: Bearer …"},
{Key: "label", Label: "Label", Placeholder: "Status"},
},
}
}
func (jsonpath) Fetch(ctx context.Context, cfg json.RawMessage) (any, error) {
url := cfgStr(cfg, "url")
path := cfgStr(cfg, "path")
if url == "" || path == "" {
return nil, fmt.Errorf("url and path required")
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return nil, fmt.Errorf("url must be http(s)")
}
var headers map[string]string
if h := cfgStr(cfg, "header"); h != "" {
if k, v, ok := strings.Cut(h, ":"); ok {
headers = map[string]string{strings.TrimSpace(k): strings.TrimSpace(v)}
}
}
var doc any
if err := getJSON(ctx, url, headers, &doc); err != nil {
return nil, err
}
v, ok := jsonPath(doc, path)
if !ok {
return nil, fmt.Errorf("path %q not found", path)
}
return map[string]any{"value": v, "label": cfgStr(cfg, "label")}, nil
}
// jsonPath walks doc along a path like "a.b[0].c". Segments split on dots;
// a trailing [n] indexes into arrays. Returns false on any miss.
func jsonPath(doc any, path string) (any, bool) {
cur := doc
for _, seg := range strings.Split(path, ".") {
name, rest, _ := strings.Cut(seg, "[")
if name != "" {
m, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = m[name]
if !ok {
return nil, false
}
}
for rest != "" {
idxStr, tail, _ := strings.Cut(rest, "]")
i, err := strconv.Atoi(idxStr)
if err != nil {
return nil, false
}
arr, ok := cur.([]any)
if !ok || i < 0 || i >= len(arr) {
return nil, false
}
cur = arr[i]
rest = strings.TrimPrefix(tail, "[")
}
}
return cur, true
}
@@ -0,0 +1,80 @@
package widget
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestJSONPathWalk(t *testing.T) {
doc := map[string]any{
"data": map[string]any{
"status": "ok",
"items": []any{map[string]any{"name": "first"}, "second"},
"nested": []any{[]any{float64(42)}},
},
}
cases := []struct {
path string
want any
ok bool
}{
{"data.status", "ok", true},
{"data.items[0].name", "first", true},
{"data.items[1]", "second", true},
{"data.nested[0][0]", float64(42), true},
{"data.missing", nil, false},
{"data.items[9]", nil, false},
{"data.items[-1]", nil, false},
{"data.status.x", nil, false},
{"data.items[x]", nil, false},
{"data.status[0]", nil, false},
}
for _, tc := range cases {
got, ok := jsonPath(doc, tc.path)
if ok != tc.ok || (ok && got != tc.want) {
t.Errorf("path %q: got %v,%v want %v,%v", tc.path, got, ok, tc.want, tc.ok)
}
}
}
func TestJSONPathFetch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"status":"degraded"}}`))
}))
defer srv.Close()
cfg, _ := json.Marshal(map[string]any{
"url": srv.URL,
"path": "data.status",
"header": "Authorization: Bearer tok",
"label": "State",
})
got, err := (jsonpath{}).Fetch(context.Background(), cfg)
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["value"] != "degraded" || m["label"] != "State" {
t.Fatalf("unexpected payload: %v", m)
}
}
func TestJSONPathFetchValidation(t *testing.T) {
for _, cfg := range []string{
`{"url":"","path":"a"}`,
`{"url":"file:///etc/passwd","path":"a"}`,
`{"url":"ftp://x","path":"a"}`,
} {
if _, err := (jsonpath{}).Fetch(context.Background(), json.RawMessage(cfg)); err == nil {
t.Errorf("expected error for %s", cfg)
}
}
}
+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Dash</title>
<script>
// Pre-paint theme: localStorage mirrors the server setting, applied
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+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

+16
View File
@@ -0,0 +1,16 @@
{
"name": "Dash",
"short_name": "Dash",
"description": "Self-hosted homelab dashboard",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#0a0a0a",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+57
View File
@@ -0,0 +1,57 @@
// Dash service worker: cache-first for static assets, network-first for /api.
// Bump CACHE on each deploy shape change; clients drop stale caches on activate.
const CACHE = "dash-v1";
const ASSET = /\.(?:js|css|woff2?|png|svg|webmanifest|ico)$/;
self.addEventListener("install", (e) => {
self.skipWaiting();
e.waitUntil(caches.open(CACHE).then((c) => c.add("/")));
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (e) => {
const url = new URL(e.request.url);
if (e.request.method !== "GET" || url.origin !== self.location.origin) return;
// API: network-first so widgets stay live; fall back to cache offline.
if (url.pathname.startsWith("/api/")) {
e.respondWith(
fetch(e.request)
.then((res) => {
const clone = res.clone();
if (res.ok) caches.open(CACHE).then((c) => c.put(e.request, clone));
return res;
})
.catch(() => caches.match(e.request)),
);
return;
}
// Navigations: network-first, fall back to cached "/" (read-only offline).
if (e.request.mode === "navigate") {
e.respondWith(fetch(e.request).catch(() => caches.match("/")));
return;
}
// Static assets: cache-first, populate on miss.
if (ASSET.test(url.pathname)) {
e.respondWith(
caches.match(e.request).then(
(hit) =>
hit ||
fetch(e.request).then((res) => {
const clone = res.clone();
if (res.ok) caches.open(CACHE).then((c) => c.put(e.request, clone));
return res;
}),
),
);
}
});
+33 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link, Route, useLocation } from "wouter";
import { Activity, AlertTriangle, Globe, LayoutGrid, Megaphone, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react";
import { BoardProvider, useBoard } from "@/board/store";
import { BoardSwitcher } from "@/components/BoardSwitcher";
import { Board } from "@/board/Board";
import { ServiceDialog } from "@/components/ServiceDialog";
import { WidgetDialog } from "@/components/WidgetDialog";
@@ -16,6 +17,8 @@ import { SystemDetailPage } from "@/pages/SystemDetailPage";
import { IncidentsPage } from "@/pages/IncidentsPage";
import { StatusPagesPage } from "@/pages/StatusPagesPage";
import { StatusPublicPage } from "@/pages/StatusPublicPage";
import { LoginPage } from "@/pages/LoginPage";
import { api } from "@/api";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t, type Key } from "@/i18n";
@@ -41,6 +44,8 @@ const NAV: { href: string; labelKey: Key; icon: typeof LayoutGrid }[] = [
function Shell() {
const board = useBoard();
const [loc] = useLocation();
// null = still checking; gate blocks everything except public status pages.
const [auth, setAuth] = useState<{ enabled?: boolean; setup?: boolean; authed?: boolean } | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [svcOpen, setSvcOpen] = useState(false);
@@ -64,12 +69,34 @@ function Shell() {
return () => window.removeEventListener("keydown", onKey);
}, []);
// Auth gate: check the session once, and again whenever the client sees a
// 401 (middleware in api.ts dispatches dash:unauthorized).
useEffect(() => {
const load = () => api.GET("/api/auth/session").then(({ data }) => setAuth(data ?? {}));
load();
window.addEventListener("dash:unauthorized", load);
return () => window.removeEventListener("dash:unauthorized", load);
}, []);
// URL is the board source of truth: / -> default, /b/<slug> -> that board.
const setActiveSlug = board.setActiveSlug;
useEffect(() => {
setActiveSlug(loc.startsWith("/b/") ? decodeURIComponent(loc.slice(3)) : "");
}, [loc, setActiveSlug]);
function openEditor(it: Item) {
setEditing(it);
if (it.kind === "widget") setWdgOpen(true);
else setSvcOpen(true);
}
// Public status pages stay reachable when auth is on — the backend serves
// their API unauthenticated, so the SPA must let the route through too.
const publicPage = loc.startsWith("/status/");
if (auth?.enabled && !auth.authed && !publicPage) {
return <LoginPage setup={!!auth.setup} onDone={() => location.reload()} />;
}
return (
<div className="font-sans">
<header className="sticky top-0 z-10 flex items-center gap-3.5 border-b border-border bg-bg/80 px-7 py-3.5 backdrop-blur-md">
@@ -82,9 +109,10 @@ function Shell() {
</svg>
Dash
</div>
<nav className="ml-6 flex items-center gap-1" aria-label="Primary">
<BoardSwitcher />
<nav className="ml-4 flex items-center gap-1" aria-label="Primary">
{NAV.map((n) => {
const active = n.href === "/" ? loc === "/" : loc.startsWith(n.href);
const active = n.href === "/" ? loc === "/" || loc.startsWith("/b/") : loc.startsWith(n.href);
return (
<Link
key={n.href}
@@ -138,6 +166,9 @@ function Shell() {
<Route path="/">
<Board onEditItem={openEditor} />
</Route>
<Route path="/b/:slug">
<Board onEditItem={openEditor} />
</Route>
<Route path="/monitors">
<MonitorsPage />
</Route>
+12
View File
@@ -2,3 +2,15 @@ import { createClient } from "@dash/api-client";
/** Same-origin typed client; vite proxies /api in dev. */
export const api = createClient();
api.use({
onResponse({ request, response }) {
// A 401 from a real endpoint means auth is enabled and the session is
// gone — tell the shell to drop back to the login gate. Auth endpoints
// handle their own 401s (bad password shows inline).
if (response.status === 401 && !request.url.includes("/api/auth/")) {
window.dispatchEvent(new Event("dash:unauthorized"));
}
return response;
},
});
+6 -5
View File
@@ -6,6 +6,7 @@ import { renderers } from "@/renderers";
import type { Item } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { t } from "@/i18n";
// Board is the renderer-agnostic glue: state + dnd + sort contexts wired once,
// the active renderer supplies the chrome.
@@ -31,7 +32,7 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) {
if (board.sections.length === 0) {
return (
<main className="mx-auto flex max-w-[1140px] flex-col items-center gap-4 px-7 pb-24 pt-24">
<p className="text-[13px] text-text-faint">No sections yet.</p>
<p className="text-[13px] text-text-faint">{t("board.empty")}</p>
<AddSectionRow
open={addingSection} setOpen={setAddingSection}
name={sectionName} setName={setSectionName} onSubmit={submitSection}
@@ -52,7 +53,7 @@ export function Board({ onEditItem }: { onEditItem: (item: Item) => void }) {
index={si}
onToggle={() => void board.patchSection(sec.id, { collapsed: !sec.collapsed })}
onDelete={() => {
if (window.confirm(`Delete section "${sec.name}" and its ${sec.items.length} items?`)) {
if (window.confirm(t("board.deleteSectionConfirm", { name: sec.name ?? "", count: sec.items.length }))) {
void board.deleteSection(sec.id);
}
}}
@@ -100,7 +101,7 @@ function AddSectionRow({
if (!open) {
return (
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<Plus size={12} /> Add section
<Plus size={12} /> {t("board.addSection")}
</Button>
);
}
@@ -110,12 +111,12 @@ function AddSectionRow({
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Section name"
placeholder={t("board.sectionNamePlaceholder")}
className="w-[200px]"
onBlur={() => !name.trim() && setOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
/>
<Button type="submit" size="sm">Add</Button>
<Button type="submit" size="sm">{t("common.add")}</Button>
</form>
);
}
+92 -15
View File
@@ -9,20 +9,33 @@ import {
} from "react";
import { api } from "@/api";
import type {
Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
Board, Item, ItemInput, ItemPatch, Prefs, RendererName, Section, StatusMap, Theme,
} from "@/types";
import { parsePrefs } from "@/types";
import { setLocale } from "@/i18n";
// Board store — single renderer-agnostic source of truth for the board tree,
// reachability statuses, and persisted settings. Renderers consume via useBoard.
interface BoardState {
sections: Section[];
boards: Board[];
boardId: string;
boardSlug: string;
statuses: StatusMap;
theme: Theme;
renderer: RendererName;
accent: string;
wallpaper: string;
customCss: string;
locale: string;
loaded: boolean;
refresh(): Promise<void>;
// slug is the URL form; "" means the default (first) board.
setActiveSlug(slug: string): void;
addBoard(name: string): Promise<Board | null>;
renameBoard(id: string, name: string): Promise<void>;
deleteBoard(id: string): Promise<void>;
addSection(name: string): Promise<Section | null>;
patchSection(id: string, patch: { name?: string; collapsed?: boolean }): Promise<void>;
deleteSection(id: string): Promise<void>;
@@ -41,14 +54,22 @@ const Ctx = createContext<BoardState | null>(null);
export function BoardProvider({ children }: { children: ReactNode }) {
const [sections, setSections] = useState<Section[]>([]);
const [boards, setBoards] = useState<Board[]>([]);
const [activeSlug, setActiveSlug] = useState("");
const [statuses, setStatuses] = useState<StatusMap>({});
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento" });
const [prefs, setPrefs] = useState<Prefs>({ theme: "dark", renderer: "bento", accent: "", wallpaper: "", customCss: "", locale: "" });
const [loaded, setLoaded] = useState(false);
// Resolve slug → id; unknown/empty slugs fall back to the first board.
const board = boards.find((b) => b.slug === activeSlug) ?? boards[0];
const boardId = board?.id ?? "";
const boardSlug = board?.slug ?? "";
const refresh = useCallback(async () => {
const { data } = await api.GET("/api/sections");
if (!boardId) return;
const { data } = await api.GET("/api/sections", { params: { query: { board: boardId } } });
if (data) setSections(data);
}, []);
}, [boardId]);
const refreshStatus = useCallback(async () => {
const { data } = await api.GET("/api/status");
@@ -57,11 +78,11 @@ export function BoardProvider({ children }: { children: ReactNode }) {
useEffect(() => {
void (async () => {
const [{ data: secs }, { data: cfg }] = await Promise.all([
api.GET("/api/sections"),
const [{ data: bs }, { data: cfg }] = await Promise.all([
api.GET("/api/boards"),
api.GET("/api/settings"),
]);
if (secs) setSections(secs);
if (bs) setBoards(bs);
if (cfg) setPrefs(parsePrefs(cfg));
setLoaded(true);
void refreshStatus();
@@ -70,7 +91,34 @@ export function BoardProvider({ children }: { children: ReactNode }) {
return () => clearInterval(t);
}, [refreshStatus]);
const { theme, renderer } = prefs;
// Sections reload whenever the active board changes.
useEffect(() => {
void refresh();
}, [refresh]);
const addBoard = useCallback(async (name: string) => {
const { data } = await api.POST("/api/boards", { body: { name } });
if (data) setBoards((bs) => [...bs, data]);
return data ?? null;
}, []);
const renameBoard = useCallback(async (id: string, name: string) => {
const { data } = await api.PATCH("/api/boards/{id}", { params: { path: { id } }, body: { name } });
if (data) setBoards((bs) => bs.map((b) => (b.id === id ? data : b)));
}, []);
const deleteBoard = useCallback(async (id: string) => {
const { error } = await api.DELETE("/api/boards/{id}", { params: { path: { id } } });
if (!error) {
setBoards((bs) => bs.filter((b) => b.id !== id));
setActiveSlug("");
}
}, []);
const { theme, renderer, accent, wallpaper, customCss, locale } = prefs;
// t() reads a module-level dict; this effect swaps it when the pref lands.
useEffect(() => setLocale(locale), [locale]);
// Apply theme: <html data-theme> drives all CSS vars. localStorage mirrors
// the server setting so index.html can pre-paint without a flash.
@@ -80,11 +128,36 @@ export function BoardProvider({ children }: { children: ReactNode }) {
localStorage.setItem("dash.theme", theme);
}, [theme, loaded]);
// Appearance overrides: accent is a CSS var the whole theme references,
// wallpaper hangs off <body>, customCss lands in a managed <style> tag.
useEffect(() => {
if (!loaded) return;
const root = document.documentElement;
if (accent) root.style.setProperty("--accent", accent);
else root.style.removeProperty("--accent");
// Quotes/backslashes stripped so the URL can't break out of url("…").
const safeWallpaper = wallpaper.replace(/["\\()]/g, "");
document.body.style.backgroundImage = safeWallpaper ? `url("${safeWallpaper}")` : "";
document.body.style.backgroundSize = wallpaper ? "cover" : "";
document.body.style.backgroundAttachment = wallpaper ? "fixed" : "";
let tag = document.getElementById("dash-custom-css");
if (customCss) {
if (!tag) {
tag = document.createElement("style");
tag.id = "dash-custom-css";
document.head.appendChild(tag);
}
tag.textContent = customCss;
} else {
tag?.remove();
}
}, [accent, wallpaper, customCss, loaded]);
const addSection = useCallback(async (name: string) => {
const { data } = await api.POST("/api/sections", { body: { name } });
const { data } = await api.POST("/api/sections", { body: { name, boardId } });
if (data) setSections((s) => [...s, data]);
return data ?? null;
}, []);
}, [boardId]);
const patchSection = useCallback(async (id: string, patch: { name?: string; collapsed?: boolean }) => {
setSections((ss) => ss.map((s) => (s.id === id ? { ...s, ...patch } : s)));
@@ -195,22 +268,26 @@ export function BoardProvider({ children }: { children: ReactNode }) {
const setSetting = useCallback(async (key: keyof Prefs, value: string) => {
setPrefs((p) => ({ ...p, [key]: value }));
// Prefs uses camelCase; wire keys keep the snake_case convention.
const wireKey = key === "customCss" ? "custom_css" : key;
const { data, error } = await api.PUT("/api/settings", {
body: { [key]: value },
body: { [wireKey]: value },
});
if (error) await refresh();
else if (data) setPrefs(parsePrefs(data));
}, [refresh]);
const value = useMemo<BoardState>(() => ({
sections, statuses, theme, renderer, loaded,
refresh, addSection, patchSection, deleteSection,
sections, boards, boardId, boardSlug, statuses, theme, renderer, accent, wallpaper, customCss, locale, loaded,
refresh, setActiveSlug, addBoard, renameBoard, deleteBoard,
addSection, patchSection, deleteSection,
addItem, patchItem, deleteItem, uploadIcon,
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
setSetting,
}), [
sections, statuses, theme, renderer, loaded,
refresh, addSection, patchSection, deleteSection,
sections, boards, boardId, boardSlug, statuses, theme, renderer, accent, wallpaper, customCss, locale, loaded,
refresh, addBoard, renameBoard, deleteBoard,
addSection, patchSection, deleteSection,
addItem, patchItem, deleteItem, uploadIcon,
moveItemLocal, moveSectionLocal, persistItemMove, persistSectionMove,
setSetting,
@@ -0,0 +1,127 @@
import { useState } from "react";
import { useLocation } from "wouter";
import { Check, ChevronsUpDown, Pencil, Trash2 } from "lucide-react";
import { useBoard } from "@/board/store";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
// BoardSwitcher — header control for navigating boards (/b/<slug>) and
// managing them. The store owns the data; this only routes and calls API.
export function BoardSwitcher() {
const board = useBoard();
const [, nav] = useLocation();
const [manage, setManage] = useState(false);
if (board.boards.length === 0) return null;
return (
<>
<Select value={board.boardSlug} onValueChange={(slug) => nav(slug === board.boards[0]?.slug ? "/" : `/b/${slug}`)}>
<SelectTrigger className="h-8 w-auto min-w-[110px] gap-1 border-none bg-transparent px-2 text-[13px] font-semibold shadow-none">
<SelectValue />
<ChevronsUpDown size={12} className="text-text-faint" />
</SelectTrigger>
<SelectContent>
{board.boards.map((b) => (
<SelectItem key={b.id} value={b.slug}>{b.name}</SelectItem>
))}
<button
type="button"
className="mt-1 w-full rounded-[6px] border-t border-border px-2 py-1.5 text-left text-[12px] text-text-faint hover:text-text"
onClick={() => setManage(true)}
>
{t("boards.manage")}
</button>
</SelectContent>
</Select>
<BoardManageDialog open={manage} onOpenChange={setManage} />
</>
);
}
function BoardManageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const board = useBoard();
const [, nav] = useLocation();
const [name, setName] = useState("");
const [renaming, setRenaming] = useState<string | null>(null);
const [renameTo, setRenameTo] = useState("");
async function create() {
const n = name.trim();
if (!n) return;
const b = await board.addBoard(n);
setName("");
if (b) {
onOpenChange(false);
nav(`/b/${b.slug}`);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("boards.title")}</DialogTitle>
</DialogHeader>
<div className="grid gap-2">
{board.boards.map((b) => (
<div key={b.id} className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
{renaming === b.id ? (
<>
<Input
value={renameTo} autoFocus
onChange={(e) => setRenameTo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void board.renameBoard(b.id, renameTo.trim()).then(() => setRenaming(null));
if (e.key === "Escape") setRenaming(null);
}}
className="h-7 text-[13px]"
/>
<Button
variant="ghost" size="icon" className="h-7 w-7" aria-label={t("common.save")}
onClick={() => void board.renameBoard(b.id, renameTo.trim()).then(() => setRenaming(null))}
>
<Check size={13} />
</Button>
</>
) : (
<>
<span className="flex-1 text-[13px]">{b.name}</span>
<span className="font-mono text-[10px] text-text-faint">/b/{b.slug}</span>
<Button
variant="ghost" size="icon" className="h-7 w-7" aria-label={t("common.edit")}
onClick={() => { setRenaming(b.id); setRenameTo(b.name); }}
>
<Pencil size={12} />
</Button>
{board.boards.length > 1 && (
<Button
variant="ghost" size="icon" className="h-7 w-7 text-danger" aria-label={t("common.delete")}
onClick={() => void board.deleteBoard(b.id).then(() => {
if (b.slug === board.boardSlug) nav("/");
})}
>
<Trash2 size={12} />
</Button>
)}
</>
)}
</div>
))}
<div className="mt-1 flex gap-2">
<Input
value={name} placeholder={t("boards.newPlaceholder")}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void create(); }}
className="h-8 text-[13px]"
/>
<Button size="sm" disabled={!name.trim()} onClick={() => void create()}>{t("common.add")}</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+23 -22
View File
@@ -7,13 +7,14 @@ import { rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import type { Item } from "@/types";
import { t } from "@/i18n";
// ⌘K palette — flat filtered list over actions + service endpoints.
// No cmdk dep: an input, a list, and an active-index is the whole mechanic.
interface Command {
id: string;
group: "Actions" | "Services";
group: "actions" | "services";
icon?: Item;
glyph?: typeof LayoutGrid;
label: string;
@@ -42,28 +43,28 @@ export function CommandPalette({
const commands = useMemo<Command[]>(() => {
const cmds: Command[] = [
{ id: "nav-board", group: "Actions", glyph: LayoutGrid, label: "Go to Board", run: () => navigate("/") },
{ id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") },
{ id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") },
{ id: "nav-systems", group: "Actions", glyph: Server, label: "Go to Systems", run: () => navigate("/systems") },
{ id: "nav-incidents", group: "Actions", glyph: AlertTriangle, label: "Go to Incidents", run: () => navigate("/incidents") },
{ id: "nav-status", group: "Actions", glyph: Megaphone, label: "Go to Status pages", run: () => navigate("/status") },
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
{ id: "nav-board", group: "actions", glyph: LayoutGrid, label: t("palette.goTo", { page: t("nav.board") }), run: () => navigate("/") },
{ id: "nav-monitors", group: "actions", glyph: Activity, label: t("palette.goTo", { page: t("nav.monitors") }), run: () => navigate("/monitors") },
{ id: "nav-domains", group: "actions", glyph: Globe, label: t("palette.goTo", { page: t("nav.domains") }), run: () => navigate("/domains") },
{ id: "nav-systems", group: "actions", glyph: Server, label: t("palette.goTo", { page: t("nav.systems") }), run: () => navigate("/systems") },
{ id: "nav-incidents", group: "actions", glyph: AlertTriangle, label: t("palette.goTo", { page: t("nav.incidents") }), run: () => navigate("/incidents") },
{ id: "nav-status", group: "actions", glyph: Megaphone, label: t("palette.goTo", { page: t("statusPages.title") }), run: () => navigate("/status") },
{ id: "add-service", group: "actions", glyph: Plus, label: t("header.addService"), hint: t("palette.hintNew"), run: onAddService },
{ id: "add-widget", group: "actions", glyph: Plus, label: t("header.addWidget"), hint: t("palette.hintNew"), run: onAddWidget },
{ id: "settings", group: "actions", glyph: Settings, label: t("palette.openSettings"), run: onSettings },
{
id: "theme", group: "Actions", glyph: board.theme === "dark" ? Sun : Moon,
label: `Switch to ${board.theme === "dark" ? "light" : "dark"} theme`,
id: "theme", group: "actions", glyph: board.theme === "dark" ? Sun : Moon,
label: t("palette.themeSwitch", { theme: board.theme === "dark" ? t("settings.light") : t("settings.dark") }),
run: () => void board.setSetting("theme", board.theme === "dark" ? "light" : "dark"),
},
...rendererList.map((r): Command => ({
id: `renderer-${r}`, group: "Actions", glyph: LayoutGrid,
label: `Renderer: ${rendererLabels[r]}`,
hint: board.renderer === r ? "active" : undefined,
id: `renderer-${r}`, group: "actions", glyph: LayoutGrid,
label: t("palette.renderer", { name: rendererLabels[r] }),
hint: board.renderer === r ? t("palette.hintActive") : undefined,
run: () => void board.setSetting("renderer", r),
})),
{
id: "export", group: "Actions", glyph: Download, label: "Export board JSON",
id: "export", group: "actions", glyph: Download, label: t("palette.exportJson"),
run: () => { window.open("/api/export", "_blank", "noopener"); },
},
];
@@ -72,7 +73,7 @@ export function CommandPalette({
if (it.kind === "widget") continue;
for (const u of it.urls) {
cmds.push({
id: `svc-${u.id}`, group: "Services", icon: it,
id: `svc-${u.id}`, group: "services", icon: it,
label: u.label ? `${it.name} — ${u.label}` : it.name,
hint: hostOf(u.url),
run: () => window.open(u.url, "_blank", "noopener"),
@@ -105,7 +106,7 @@ export function CommandPalette({
c.run();
}
const groups: { name: string; items: { c: Command; i: number }[] }[] = [];
const groups: { name: Command["group"]; items: { c: Command; i: number }[] }[] = [];
filtered.forEach((c, i) => {
const g = groups.find((g) => g.name === c.group);
if (g) g.items.push({ c, i });
@@ -115,7 +116,7 @@ export function CommandPalette({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="top-[18%] max-w-[520px] translate-y-0 gap-0 overflow-hidden p-0" showCloseButton={false}>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogTitle className="sr-only">{t("palette.title")}</DialogTitle>
<div className="flex items-center gap-2.5 border-b border-border px-4">
<input
autoFocus
@@ -126,18 +127,18 @@ export function CommandPalette({
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(clamped - 1); }
else if (e.key === "Enter") { e.preventDefault(); pick(clamped); }
}}
placeholder="Search services and actions…"
placeholder={t("palette.placeholder")}
className="h-12 w-full bg-transparent text-[14px] outline-none placeholder:text-text-faint focus-visible:outline-none"
/>
<kbd className="shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-faint">esc</kbd>
</div>
<div ref={listRef} className="max-h-[340px] overflow-y-auto p-1.5">
{filtered.length === 0 ? (
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">No matches.</p>
<p className="px-3 py-6 text-center text-[12.5px] text-text-faint">{t("palette.noResults")}</p>
) : groups.map((g) => (
<div key={g.name}>
<div className="px-2.5 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
{g.name}
{t(`palette.${g.name}`)}
</div>
{g.items.map(({ c, i }) => (
<button
+35 -12
View File
@@ -7,6 +7,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/i18n";
// Add/edit a tracked domain. The first WHOIS/DNS/TLS lookup runs on create —
// expect a few seconds of spinner.
@@ -26,6 +27,8 @@ export function DomainDialog({
const [intervalH, setIntervalH] = useState("24");
const [alertDays, setAlertDays] = useState("30");
const [autoRenew, setAutoRenew] = useState(false);
const [certDays, setCertDays] = useState("");
const [mute, setMute] = useState(false);
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
@@ -37,11 +40,13 @@ export function DomainDialog({
setIntervalH(String(domain?.intervalH ?? 24));
setAlertDays(String(domain?.alertDaysBefore ?? 30));
setAutoRenew(domain?.autoRenew ?? false);
setCertDays(domain?.alerts?.certDays ? String(domain.alerts.certDays) : "");
setMute(domain?.alerts?.mute ?? false);
setNotes(domain?.notes ?? "");
}, [open, domain]);
async function submit() {
if (!name.trim()) return setErr("Domain is required"), undefined;
if (!name.trim()) return setErr(t("domains.nameRequired")), undefined;
setBusy(true);
const body: DomainInput = {
name: name.trim(),
@@ -49,6 +54,10 @@ export function DomainDialog({
alertDaysBefore: Math.max(0, parseInt(alertDays) || 30),
autoRenew,
notes,
alerts: {
certDays: Math.max(0, parseInt(certDays) || 0),
mute,
},
};
try {
if (editing && domain) {
@@ -60,7 +69,7 @@ export function DomainDialog({
onSaved();
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("domains.saveFailed"));
} finally {
setBusy(false);
}
@@ -70,37 +79,51 @@ export function DomainDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "Edit domain" : "Track a domain"}</DialogTitle>
<DialogTitle>{editing ? t("domains.editTitle") : t("domains.addTitle")}</DialogTitle>
<DialogDescription>
WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.
{t("domains.addDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-name">Domain</Label>
<Label htmlFor="dom-name">{t("domains.domain")}</Label>
<Input id="dom-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="example.com" className="font-mono text-[12px]" autoFocus />
placeholder={t("domains.namePlaceholder")} className="font-mono text-[12px]" autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-int">Refresh every (hours)</Label>
<Label htmlFor="dom-int">{t("domains.refreshEvery")}</Label>
<Input id="dom-int" value={intervalH} onChange={(e) => setIntervalH(e.target.value)} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-alert">Alert days before expiry</Label>
<Label htmlFor="dom-alert">{t("domains.alertDays")}</Label>
<Input id="dom-alert" value={alertDays} onChange={(e) => setAlertDays(e.target.value)} className="font-mono text-[12px]" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-cert">{t("domains.certDaysLbl")}</Label>
<Input id="dom-cert" value={certDays} onChange={(e) => setCertDays(e.target.value)}
placeholder={t("domains.certDaysPlaceholder")} className="font-mono text-[12px]" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={mute} onChange={(e) => setMute(e.target.checked)} />
{t("monitors.mute")}
</label>
</div>
</div>
<label className="flex items-center gap-2 text-[12.5px] text-text-dim">
<input type="checkbox" checked={autoRenew} onChange={(e) => setAutoRenew(e.target.checked)} />
Auto-renew enabled at the registrar
{t("domains.autoRenewLbl")}
</label>
<div className="flex flex-col gap-1.5">
<Label htmlFor="dom-notes">Notes (optional)</Label>
<Label htmlFor="dom-notes">{t("domains.notesOptional")}</Label>
<Input id="dom-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
@@ -109,10 +132,10 @@ export function DomainDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Looking up…" : editing ? "Save" : "Track domain"}
{busy ? t("domains.lookingUp") : editing ? t("common.save") : t("domains.new")}
</Button>
</DialogFooter>
</DialogContent>
+52 -26
View File
@@ -8,6 +8,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
const TYPES = ["http", "tcp", "ping", "dns", "keyword", "json", "push"] as const;
type MonType = (typeof TYPES)[number];
@@ -48,6 +49,9 @@ export function MonitorDialog({
intervalS: String(monitor?.intervalS ?? 60),
timeoutS: String(monitor?.timeoutS ?? 10),
retries: String(monitor?.retries ?? 0),
consecFails: String(monitor?.alerts?.consecutiveFailures ?? ""),
latencyWarn: String(monitor?.alerts?.latencyWarnMs ?? ""),
mute: monitor?.alerts?.mute ? "1" : "",
notes: monitor?.notes ?? "",
});
}, [open, monitor]);
@@ -59,11 +63,11 @@ export function MonitorDialog({
const type = (f.type || "http") as MonType;
async function submit() {
if (!f.name?.trim()) return setErr("Name is required"), undefined;
if (!f.name?.trim()) return setErr(t("monitors.nameRequired")), undefined;
if (type !== "push" && type !== "tcp" && type !== "ping" && type !== "dns" && !f.url?.trim())
return setErr("URL is required"), undefined;
return setErr(t("monitors.urlRequired")), undefined;
if ((type === "tcp" || type === "ping" || type === "dns") && !f.hostname?.trim() && !f.url?.trim())
return setErr("Hostname is required"), undefined;
return setErr(t("monitors.hostnameRequired")), undefined;
setBusy(true);
const body: MonitorInput = {
name: f.name.trim(),
@@ -81,6 +85,11 @@ export function MonitorDialog({
timeoutS: Math.max(1, parseInt(f.timeoutS ?? "10") || 10),
retries: Math.max(0, parseInt(f.retries ?? "0") || 0),
notes: f.notes ?? "",
alerts: {
consecutiveFailures: Math.max(1, parseInt(f.consecFails ?? "") || 1),
latencyWarnMs: Math.max(0, parseInt(f.latencyWarn ?? "") || 0),
mute: f.mute === "1",
},
};
try {
if (editing && monitor) {
@@ -91,7 +100,7 @@ export function MonitorDialog({
onSaved();
onOpenChange(false);
} catch {
setErr("save failed");
setErr(t("monitors.saveFailed"));
} finally {
setBusy(false);
}
@@ -101,18 +110,18 @@ export function MonitorDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "Edit monitor" : "New monitor"}</DialogTitle>
<DialogDescription>Uptime check on a schedule.</DialogDescription>
<DialogTitle>{editing ? t("monitors.editTitle") : t("monitors.newTitle")}</DialogTitle>
<DialogDescription>{t("monitors.desc")}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto pr-1">
<div className="grid grid-cols-[1fr_130px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-name">Name</Label>
<Label htmlFor="mon-name">{t("common.name")}</Label>
<Input id="mon-name" value={f.name ?? ""} onChange={set("name")} placeholder="Plex" autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Label>{t("common.type")}</Label>
<Select value={type} onValueChange={(v) => setF((s) => ({ ...s, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -126,11 +135,11 @@ export function MonitorDialog({
<>
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-url">URL</Label>
<Label htmlFor="mon-url">{t("common.url")}</Label>
<Input id="mon-url" value={f.url ?? ""} onChange={set("url")} placeholder="https://plex.local" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label>Method</Label>
<Label>{t("monitors.method")}</Label>
<Select value={f.method || "GET"} onValueChange={(v) => setF((s) => ({ ...s, method: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -141,23 +150,23 @@ export function MonitorDialog({
</div>
{type === "keyword" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-kw">Keyword</Label>
<Label htmlFor="mon-kw">{t("monitors.keyword")}</Label>
<Input id="mon-kw" value={f.keyword ?? ""} onChange={set("keyword")} placeholder="text that must appear" className="font-mono text-[12px]" />
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={f.keywordInvert === "1"}
onChange={(e) => setF((s) => ({ ...s, keywordInvert: e.target.checked ? "1" : "" }))} />
Invert (down when found)
{t("monitors.keywordInvert")}
</label>
</div>
)}
{type === "json" && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-jq">JSON path</Label>
<Label htmlFor="mon-jq">{t("monitors.jsonPath")}</Label>
<Input id="mon-jq" value={f.jsonQuery ?? ""} onChange={set("jsonQuery")} placeholder="data.status" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-exp">Expected</Label>
<Label htmlFor="mon-exp">{t("monitors.expected")}</Label>
<Input id="mon-exp" value={f.expected ?? ""} onChange={set("expected")} placeholder="ok" className="font-mono text-[12px]" />
</div>
</div>
@@ -168,17 +177,17 @@ export function MonitorDialog({
{(type === "tcp" || type === "ping" || type === "dns") && (
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-host">Hostname</Label>
<Label htmlFor="mon-host">{t("common.hostname")}</Label>
<Input id="mon-host" value={f.hostname ?? ""} onChange={set("hostname")} placeholder="nas.local" className="font-mono text-[12px]" />
</div>
{type === "tcp" ? (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-port">Port</Label>
<Label htmlFor="mon-port">{t("common.port")}</Label>
<Input id="mon-port" value={f.port ?? ""} onChange={set("port")} placeholder="443" className="font-mono text-[12px]" />
</div>
) : type === "dns" ? (
<div className="flex flex-col gap-1.5">
<Label>Record</Label>
<Label>{t("monitors.record")}</Label>
<Select value={f.dnsType || "A"} onValueChange={(v) => setF((s) => ({ ...s, dnsType: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -191,36 +200,53 @@ export function MonitorDialog({
)}
{type === "dns" && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-dnsexp">Expected record contains (optional)</Label>
<Label htmlFor="mon-dnsexp">{t("monitors.dnsExpected")}</Label>
<Input id="mon-dnsexp" value={f.expected ?? ""} onChange={set("expected")} className="font-mono text-[12px]" />
</div>
)}
{type === "push" && (
<p className="rounded-[8px] border border-border bg-surface px-3 py-2 text-[12px] text-text-dim">
After saving, your jobs call the shown push URL on a schedule.
If it goes quiet for 2× the interval, the monitor flips down.
{t("monitors.pushHint")}
</p>
)}
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-int">Interval (s)</Label>
<Label htmlFor="mon-int">{t("monitors.interval")}</Label>
<Input id="mon-int" value={f.intervalS ?? "60"} onChange={set("intervalS")} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-to">Timeout (s)</Label>
<Label htmlFor="mon-to">{t("monitors.timeout")}</Label>
<Input id="mon-to" value={f.timeoutS ?? "10"} onChange={set("timeoutS")} className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-re">Retries</Label>
<Label htmlFor="mon-re">{t("monitors.retries")}</Label>
<Input id="mon-re" value={f.retries ?? "0"} onChange={set("retries")} className="font-mono text-[12px]" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-cf">{t("monitors.failsBeforeDown")}</Label>
<Input id="mon-cf" value={f.consecFails ?? ""} onChange={set("consecFails")} placeholder="1" className="font-mono text-[12px]" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mon-lw">{t("monitors.latencyWarn")}</Label>
<Input id="mon-lw" value={f.latencyWarn ?? ""} onChange={set("latencyWarn")} placeholder="off" className="font-mono text-[12px]" />
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-[12px] text-text-dim">
<input type="checkbox" checked={f.mute === "1"}
onChange={(e) => setF((s) => ({ ...s, mute: e.target.checked ? "1" : "" }))} />
{t("monitors.mute")}
</label>
</div>
</div>
{monitor?.pushToken ? (
<div className="flex flex-col gap-1.5">
<Label>Push URL</Label>
<Label>{t("monitors.pushUrl")}</Label>
<code className="rounded-[8px] border border-border bg-surface px-3 py-2 font-mono text-[11px] text-text-dim">
{location.origin}/api/push/{monitor.pushToken}
</code>
@@ -232,10 +258,10 @@ export function MonitorDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add monitor"}
{busy ? t("common.saving") : editing ? t("common.save") : t("monitors.addMonitor")}
</Button>
</DialogFooter>
</DialogContent>
+21 -20
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { t } from "@/i18n";
// dashboard-icons CDN serves png/<slug>.png — slugify the service name for
// the one-click suggestion under the icon field.
@@ -58,9 +59,9 @@ export function ServiceDialog({
async function submit() {
const cleanUrls = urls.filter((u) => u.url.trim() !== "");
if (!name.trim()) return setErr("Name is required"), undefined;
if (!name.trim()) return setErr(t("service.nameRequired")), undefined;
if (cleanUrls.some((u) => !/^https?:\/\/.+/.test(u.url))) {
return setErr("URLs must start with http:// or https://"), undefined;
return setErr(t("service.urlRequired")), undefined;
}
setBusy(true);
try {
@@ -72,7 +73,7 @@ export function ServiceDialog({
let sid = sectionId;
if (!sid) {
// First service on an empty board — create the home section.
const sec = await board.addSection("Services");
const sec = await board.addSection(t("statusPages.services"));
if (!sec) throw new Error("could not create section");
sid = sec.id;
}
@@ -93,7 +94,7 @@ export function ServiceDialog({
}
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("service.saveFailed"));
} finally {
setBusy(false);
}
@@ -111,21 +112,21 @@ export function ServiceDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit service" : "Add service"}</DialogTitle>
<DialogTitle>{editing ? t("service.editTitle") : t("service.newTitle")}</DialogTitle>
<DialogDescription>
{editing ? "Update name, icon, or launch URLs." : "Name it, give it an icon, add one or more URLs."}
{editing ? t("service.editDesc") : t("service.newDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_150px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-name">Name</Label>
<Label htmlFor="svc-name">{t("common.name")}</Label>
<Input id="svc-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
</div>
{!editing && board.sections.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label>Section</Label>
<Label>{t("widget.section")}</Label>
<Select value={sectionId} onValueChange={setSectionId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -139,7 +140,7 @@ export function ServiceDialog({
</div>
<div className="flex flex-col gap-1.5">
<Label>URLs</Label>
<Label>{t("service.urls")}</Label>
{urls.map((u, i) => (
<div key={i} className="flex gap-2">
<Input
@@ -151,13 +152,13 @@ export function ServiceDialog({
<Input
value={u.label ?? ""}
onChange={(e) => setUrl(i, { label: e.target.value })}
placeholder="label"
placeholder={t("service.labelPlaceholder")}
className="w-[110px] shrink-0"
list="url-labels"
/>
<Button
type="button" variant="ghost" size="icon"
aria-label="Remove URL"
aria-label={t("service.removeUrl")}
disabled={urls.length <= 1}
onClick={() => setUrls((us) => us.filter((_, j) => j !== i))}
>
@@ -173,16 +174,16 @@ export function ServiceDialog({
className="mt-1 w-fit"
onClick={() => setUrls((us) => [...us, { url: "", label: "" }])}
>
<Plus size={12} /> Add URL
<Plus size={12} /> {t("service.addUrl")}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="svc-icon">Icon URL</Label>
<Label htmlFor="svc-icon">{t("service.icon")}</Label>
<div className="flex items-center gap-2">
<IconPreview url={icon} />
<Input id="svc-icon" value={icon} onChange={(e) => setIcon(e.target.value)} placeholder="https://…/icon.png" className="font-mono text-[12px]" />
<Button type="button" variant="outline" size="icon" aria-label="Upload icon file" onClick={() => fileRef.current?.click()}>
<Button type="button" variant="outline" size="icon" aria-label={t("service.upload")} onClick={() => fileRef.current?.click()}>
<Upload size={13} />
</Button>
<input
@@ -196,10 +197,10 @@ export function ServiceDialog({
onClick={() => setIcon(`${DI_BASE}/${diSlug(name)}.png`)}
className="w-fit rounded border border-border px-1.5 py-0.5 font-mono text-[10.5px] text-text-faint transition-colors hover:border-border-strong hover:text-text"
>
suggest: {diSlug(name)}.png
{t("service.suggest", { slug: diSlug(name) })}
</button>
) : null}
<p className="text-[11px] text-text-faint">URL or uploaded file; blank shows a letter tile.</p>
<p className="text-[11px] text-text-faint">{t("service.iconHint")}</p>
</div>
{!editing && (
@@ -209,7 +210,7 @@ export function ServiceDialog({
checked={alsoMonitor}
onChange={(e) => setAlsoMonitor(e.target.checked)}
/>
Also create an uptime monitor for the first URL
{t("service.alsoMonitor")}
</label>
)}
@@ -219,14 +220,14 @@ export function ServiceDialog({
<DialogFooter className="items-center">
{editing ? (
<Button type="button" variant="destructive" size="sm" className="mr-auto" onClick={() => void remove()} disabled={busy}>
<Trash2 size={12} /> Delete
<Trash2 size={12} /> {t("common.delete")}
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add service"}
{busy ? t("common.saving") : editing ? t("common.save") : t("service.addService")}
</Button>
</DialogFooter>
</DialogContent>
+204 -40
View File
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { Bell, Download, Upload } from "lucide-react";
import { Bell, Download, Lock, Upload } from "lucide-react";
import { useBoard } from "@/board/store";
import { api } from "@/api";
import { rendererDescriptions, rendererLabels, rendererList } from "@/renderers";
import { cn } from "@/lib/utils";
import { LOCALES, t } from "@/i18n";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
@@ -12,26 +13,100 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// Notification transports — mirrors the backend registry. Config is stored
// under notify_<name> as a JSON object; legacy notify_webhook strings map to
// {url}.
const TRANSPORTS = {
webhook: { label: "Webhook", fields: [{ k: "url", l: "URL", ph: "https://…" }] },
slack: { label: "Slack", fields: [{ k: "url", l: "Webhook URL", ph: "https://hooks.slack.com/…" }] },
discord: { label: "Discord", fields: [{ k: "url", l: "Webhook URL", ph: "https://discord.com/api/webhooks/…" }] },
telegram: { label: "Telegram", fields: [
{ k: "token", l: "Bot token", ph: "123456:ABC…", secret: true },
{ k: "chat_id", l: "Chat ID", ph: "-100…" },
{ k: "server", l: "API server (optional)", ph: "https://api.telegram.org" },
] },
gotify: { label: "Gotify", fields: [
{ k: "server", l: "Server", ph: "https://gotify.local" },
{ k: "token", l: "App token", secret: true },
] },
ntfy: { label: "ntfy", fields: [
{ k: "server", l: "Server", ph: "https://ntfy.sh" },
{ k: "topic", l: "Topic", ph: "dash-alerts" },
] },
smtp: { label: "SMTP", fields: [
{ k: "host", l: "Host", ph: "smtp.local" },
{ k: "port", l: "Port", ph: "587" },
{ k: "user", l: "User (optional)" },
{ k: "pass", l: "Password", secret: true },
{ k: "from", l: "From", ph: "dash@local" },
{ k: "to", l: "To", ph: "me@local, oncall@local" },
] },
} as const;
type TransportName = keyof typeof TRANSPORTS;
export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const board = useBoard();
const fileRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState("");
const [webhook, setWebhook] = useState("");
const [webhookMsg, setWebhookMsg] = useState("");
const [transport, setTransport] = useState<TransportName>("webhook");
const [notifyCfg, setNotifyCfg] = useState<Record<string, Record<string, string>>>({});
const [notifyMsg, setNotifyMsg] = useState("");
const [auth, setAuth] = useState<{ enabled?: boolean; authed?: boolean; username?: string }>({});
useEffect(() => {
if (!open) return;
void api.GET("/api/auth/session").then(({ data }) => setAuth(data ?? {}));
void api.GET("/api/settings").then(({ data }) => {
const v = data?.notify_webhook;
setWebhook(typeof v === "string" ? v : "");
setWebhookMsg("");
const cfg: Record<string, Record<string, string>> = {};
for (const name of Object.keys(TRANSPORTS)) {
const v = data?.["notify_" + name];
if (typeof v === "string") cfg[name] = { url: v }; // legacy webhook
else if (v !== null && typeof v === "object" && !Array.isArray(v)) {
const obj: Record<string, string> = {};
for (const [k, val] of Object.entries(v)) if (typeof val === "string") obj[k] = val;
cfg[name] = obj;
}
}
setNotifyCfg(cfg);
setNotifyMsg("");
});
}, [open]);
async function saveWebhook() {
setWebhookMsg("");
await api.PUT("/api/settings", { body: { notify_webhook: webhook.trim() } });
setWebhookMsg("saved");
const fields = TRANSPORTS[transport].fields;
const cfg = notifyCfg[transport] ?? {};
const setField = (k: string, v: string) => {
setNotifyCfg((s) => ({ ...s, [transport]: { ...s[transport], [k]: v } }));
setNotifyMsg("");
};
async function saveNotify() {
setNotifyMsg("");
await api.PUT("/api/settings", { body: { ["notify_" + transport]: cfg } });
setNotifyMsg(t("settings.saved"));
}
async function testNotify() {
setNotifyMsg("");
const { data, error } = await api.POST("/api/notify/test", { body: { transport } });
const r = data?.results?.[transport];
setNotifyMsg(error ? t("settings.failed") : r === "ok" ? t("settings.sent") : (r ?? t("settings.sent")));
}
// Enabling auth flips the whole app to the setup screen — reload so the
// gate picks it up. Same on sign-out/disable.
async function enableAuth() {
await api.PUT("/api/settings", { body: { auth_enabled: true } });
location.reload();
}
async function signOut() {
await api.POST("/api/auth/logout");
location.reload();
}
async function disableAuth() {
await api.POST("/api/auth/disable");
location.reload();
}
async function exportJson() {
@@ -62,7 +137,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
} else {
// SAFETY: error responses always carry {error: string} per openapi.yaml Error schema.
const body = (await res.json().catch(() => null)) as { error?: string } | null;
setMsg(body?.error ?? "Import failed");
setMsg(body?.error ?? t("settings.importFailed"));
}
}
@@ -70,14 +145,14 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Board layout, theme, and backup.</DialogDescription>
<DialogTitle>{t("settings.title")}</DialogTitle>
<DialogDescription>{t("settings.desc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto pr-1">
<div className="flex flex-col gap-1.5">
<Label>Renderer</Label>
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label="Renderer">
<Label>{t("settings.renderer")}</Label>
<div className="grid grid-cols-2 gap-1.5" role="radiogroup" aria-label={t("settings.renderer")}>
{rendererList.map((r) => (
<button
key={r}
@@ -100,56 +175,145 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
</div>
<div className="flex flex-col gap-1.5">
<Label>Theme</Label>
<Label>{t("settings.theme")}</Label>
<Select value={board.theme} onValueChange={(v) => {
if (v === "dark" || v === "light") void board.setSetting("theme", v);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">{t("settings.dark")}</SelectItem>
<SelectItem value="light">{t("settings.light")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="notify-webhook">Notification webhook</Label>
<Label>{t("settings.language")}</Label>
<Select
value={board.locale || "en"}
onValueChange={(v) => void board.setSetting("locale", v)}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(LOCALES).map(([k, name]) => (
<SelectItem key={k} value={k}>{name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="accent">{t("settings.accent")}</Label>
<div className="flex gap-2">
<input
id="accent" type="color" aria-label={t("settings.accent")}
value={/^#[0-9a-fA-F]{6}$/.test(board.accent) ? board.accent : "#3b82f6"}
onChange={(e) => void board.setSetting("accent", e.target.value)}
className="h-8 w-12 cursor-pointer rounded-[6px] border border-border bg-surface p-0.5"
/>
<Button type="button" variant="outline" size="sm" onClick={() => void board.setSetting("accent", "")}>
{t("common.reset")}
</Button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wallpaper">{t("settings.wallpaper")}</Label>
<Input
id="notify-webhook"
value={webhook}
onChange={(e) => { setWebhook(e.target.value); setWebhookMsg(""); }}
placeholder="https://ntfy.sh/dash or Slack/Discord hook"
id="wallpaper"
value={board.wallpaper}
onChange={(e) => void board.setSetting("wallpaper", e.target.value)}
placeholder={t("settings.wallpaperPh")}
className="font-mono text-[12px]"
/>
<Button type="button" variant="outline" size="sm" onClick={() => void saveWebhook()}>
Save
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="custom-css">{t("settings.customCss")}</Label>
<textarea
id="custom-css"
value={board.customCss}
onChange={(e) => void board.setSetting("customCss", e.target.value)}
placeholder=".item-tile { border-radius: 0; }"
rows={3}
className="w-full resize-y rounded-[7px] border border-border bg-bg px-2.5 py-2 font-mono text-[11.5px] outline-none focus:border-border-strong"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("settings.notifications")}</Label>
<div className="flex gap-2">
<Select value={transport} onValueChange={(v) => {
// SAFETY: Select only yields TRANSPORTS keys.
setTransport(v as TransportName); setNotifyMsg("");
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(TRANSPORTS).map(([k, v]) => (
<SelectItem key={k} value={k}>
{v.label}{notifyCfg[k] ? " ·" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" onClick={() => void saveNotify()}>{t("common.save")}</Button>
<Button
type="button" variant="outline" size="icon" aria-label="Send test notification"
disabled={!webhook.trim()}
onClick={() => {
setWebhookMsg("");
void api.POST("/api/notify/test").then(({ error }) =>
setWebhookMsg(error ? "failed" : "sent"));
}}
type="button" variant="outline" size="icon" aria-label={t("settings.testNotify")}
onClick={() => void testNotify()}
>
<Bell size={12} />
</Button>
</div>
{fields.map((f) => (
<div key={f.k} className="flex flex-col gap-1">
<Label htmlFor={`ntf-${f.k}`} className="text-[11px] text-text-faint">{f.l}</Label>
<Input
id={`ntf-${f.k}`}
type={"secret" in f && f.secret ? "password" : "text"}
value={cfg[f.k] ?? ""}
onChange={(e) => setField(f.k, e.target.value)}
placeholder={"ph" in f ? f.ph : ""}
className="font-mono text-[12px]"
/>
</div>
))}
<p className="text-[11px] text-text-faint">
Monitor up/down flips and domain/certificate expiry post JSON here.
Works with Slack, Discord, ntfy, or any JSON receiver.
{t("settings.notifyFanout")}
</p>
{notifyMsg ? <p className="text-[11px] text-text-faint">{notifyMsg}</p> : null}
</div>
<div className="flex flex-col gap-2.5 rounded-lg border border-border p-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1.5">
<Lock size={12} /> {t("auth.section")}
</Label>
{auth.enabled ? (
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => void signOut()}>
{t("auth.signOut")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => void disableAuth()}>
{t("auth.disable")}
</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={() => void enableAuth()}>
{t("auth.enable")}
</Button>
)}
</div>
<p className="text-[11px] text-text-faint">
{auth.enabled ? `${t("auth.onHint")} ${auth.username ?? ""}` : t("auth.offHint")}
</p>
{webhookMsg ? <p className="text-[11px] text-text-faint">{webhookMsg}</p> : null}
</div>
<div className="flex gap-2 pt-1">
<Button type="button" variant="outline" size="sm" onClick={() => void exportJson()}>
<Download size={12} /> Export JSON
<Download size={12} /> {t("settings.exportJson")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}>
<Upload size={12} /> Import
<Upload size={12} /> {t("settings.import")}
</Button>
<input
ref={fileRef} type="file" accept=".json,.yml,.yaml" className="hidden"
@@ -160,7 +324,7 @@ export function SettingsDialog({ open, onOpenChange }: { open: boolean; onOpenCh
}}
/>
</div>
<p className="text-[11px] text-text-faint">Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.</p>
<p className="text-[11px] text-text-faint">{t("settings.importHint")}</p>
{msg ? <p className="text-[12px] text-destructive">{msg}</p> : null}
</div>
</DialogContent>
+16 -15
View File
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { components } from "@dash/api-client";
import { t } from "@/i18n";
type WidgetType = components["schemas"]["WidgetType"];
@@ -66,10 +67,10 @@ export function WidgetDialog({
const meta = types.find((t) => t.type === type);
async function submit() {
if (!type) return setErr("Pick a widget type"), undefined;
if (!name.trim()) return setErr("Name is required"), undefined;
if (!type) return setErr(t("widget.pickType")), undefined;
if (!name.trim()) return setErr(t("service.nameRequired")), undefined;
if (meta?.fields.some((f) => f.required && !values[f.key]?.trim())) {
return setErr("Fill in the required fields"), undefined;
return setErr(t("widget.requiredFields")), undefined;
}
setBusy(true);
try {
@@ -83,7 +84,7 @@ export function WidgetDialog({
} else {
let sid = sectionId;
if (!sid) {
const sec = await board.addSection("Widgets");
const sec = await board.addSection(t("widget.defaultSection"));
if (!sec) throw new Error("could not create section");
sid = sec.id;
}
@@ -91,7 +92,7 @@ export function WidgetDialog({
}
onOpenChange(false);
} catch (e) {
setErr(e instanceof Error ? e.message : "save failed");
setErr(e instanceof Error ? e.message : t("service.saveFailed"));
} finally {
setBusy(false);
}
@@ -101,21 +102,21 @@ export function WidgetDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit widget" : "Add widget"}</DialogTitle>
<DialogTitle>{editing ? t("widget.editTitle") : t("widget.newTitle")}</DialogTitle>
<DialogDescription>
{editing ? "Update widget settings." : "Pick a type and point it at your service."}
{editing ? t("widget.editDesc") : t("widget.newDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-[1fr_150px] gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="wdg-name">Name</Label>
<Label htmlFor="wdg-name">{t("common.name")}</Label>
<Input id="wdg-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Pi-hole" autoFocus />
</div>
{!editing && board.sections.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label>Section</Label>
<Label>{t("widget.section")}</Label>
<Select value={sectionId} onValueChange={setSectionId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -129,9 +130,9 @@ export function WidgetDialog({
</div>
<div className="flex flex-col gap-1.5">
<Label>Type</Label>
<Label>{t("common.type")}</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={t("widget.pickOne")} /></SelectTrigger>
<SelectContent>
{types.map((t) => (
<SelectItem key={t.type} value={t.type}>{t.name}</SelectItem>
@@ -146,14 +147,14 @@ export function WidgetDialog({
{meta?.fields.map((f) => (
<div key={f.key} className="flex flex-col gap-1.5">
<Label htmlFor={`wdg-${f.key}`}>
{f.label}{f.required ? "" : " (optional)"}
{f.label}{f.required ? "" : ` (${t("common.optional")})`}
</Label>
{f.key === "monitorId" || f.key === "domainId" || f.key === "systemId" ? (
<Select
value={values[f.key] ?? ""}
onValueChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
>
<SelectTrigger><SelectValue placeholder="Pick one…" /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={t("widget.pickOne")} /></SelectTrigger>
<SelectContent>
{(f.key === "monitorId" ? monitors : f.key === "domainId" ? domains : systems).map((m) => (
<SelectItem key={m.id} value={m.id ?? ""}>{m.name}</SelectItem>
@@ -178,10 +179,10 @@ export function WidgetDialog({
<DialogFooter className="items-center">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
{t("common.cancel")}
</Button>
<Button type="button" onClick={() => void submit()} disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Add widget"}
{busy ? t("common.saving") : editing ? t("common.save") : t("header.addWidget")}
</Button>
</DialogFooter>
</DialogContent>
+2 -1
View File
@@ -3,6 +3,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@@ -27,7 +28,7 @@ function DialogContent({
{showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-text-faint transition-colors hover:bg-surface-hover hover:text-text focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">
<X size={14} />
<span className="sr-only">Close</span>
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
+414
View File
@@ -0,0 +1,414 @@
import type { Key } from "@/i18n/en";
// Czech strings. Partial — missing keys fall back to English in t().
export const cs: Partial<Record<Key, string>> = {
"nav.board": "Nástěnka",
"nav.monitors": "Monitory",
"nav.domains": "Domény",
"nav.systems": "Systémy",
"nav.incidents": "Incidenty",
"nav.status": "Stav",
"header.search": "Hledat (⌘K)",
"header.settings": "Nastavení",
"header.theme": "Přepnout motiv",
"header.addWidget": "Přidat widget",
"header.addService": "Přidat službu",
"header.widget": "Widget",
"common.add": "Přidat",
"common.edit": "Upravit",
"common.delete": "Smazat",
"common.cancel": "Zrušit",
"common.save": "Uložit",
"common.create": "Vytvořit",
"common.creating": "Vytvářím…",
"common.saving": "Ukládám…",
"common.test": "Test",
"common.loading": "Načítám…",
"common.name": "Název",
"common.title": "Titulek",
"common.description": "Popis",
"common.slug": "Slug",
"common.url": "URL",
"common.type": "Typ",
"common.hostname": "Hostname",
"common.port": "Port",
"common.optional": "volitelné",
"common.enabled": "Zapnuto",
"common.refresh": "Obnovit",
"common.never": "nikdy",
"common.all": "Vše",
"common.reset": "Resetovat",
"common.close": "Zavřít",
"auth.setupTitle": "Vytvořte si účet správce",
"auth.setupHint": "První uživatel se stane správcem. Pouze lokální účet — žádná externí identita.",
"auth.signInTitle": "Přihlásit se",
"auth.signInHint": "Tato instance Dash vyžaduje přihlášení.",
"auth.username": "Uživatelské jméno nebo e-mail",
"auth.password": "Heslo",
"auth.createAccount": "Vytvořit účet",
"auth.signIn": "Přihlásit se",
"auth.invalid": "Neplatné jméno nebo heslo.",
"auth.rateLimited": "Příliš mnoho pokusů — počkejte minutu.",
"auth.section": "Ověřování",
"auth.offHint": "Kdokoli, kdo dosáhne na tuto instanci, má plný přístup. Zapněte pro vyžadování přihlášení.",
"auth.enable": "Zapnout ověřování",
"auth.onHint": "Přihlášen jako",
"auth.signOut": "Odhlásit se",
"auth.disable": "Vypnout ověřování",
"auth.disabledNote": "Ověřování je vypnuté — nástěnka je otevřená vaší síti.",
"boards.title": "Nástěnky",
"boards.manage": "Spravovat nástěnky…",
"boards.newPlaceholder": "Název nové nástěnky",
"board.empty": "Zatím žádné sekce.",
"board.emptyHint": "Přidejte službu nebo widget.",
"board.addSection": "Přidat sekci",
"board.sectionNamePlaceholder": "Název sekce",
"board.deleteSectionConfirm": "Smazat sekci \"{name}\" a jejích {count} položek?",
"palette.title": "Příkazová paleta",
"palette.placeholder": "Hledat služby a akce…",
"palette.noResults": "Nic nenalezeno.",
"palette.pages": "Stránky",
"palette.services": "Služby",
"palette.actions": "Akce",
"palette.goTo": "Přejít na {page}",
"palette.openSettings": "Otevřít nastavení",
"palette.themeSwitch": "Přepnout na motiv {theme}",
"palette.renderer": "Vzhled: {name}",
"palette.exportJson": "Exportovat nástěnku do JSON",
"palette.hintNew": "nové",
"palette.hintActive": "aktivní",
"settings.title": "Nastavení",
"settings.desc": "Rozvržení nástěnky, motiv a záloha.",
"settings.theme": "Motiv",
"settings.language": "Jazyk",
"settings.dark": "Tmavý",
"settings.light": "Světlý",
"settings.renderer": "Vzhled nástěnky",
"settings.appearance": "Vzhled",
"settings.accent": "Akcentní barva",
"settings.accentHint": "Libovolná CSS barva — hex, hsl, pojmenovaná.",
"settings.wallpaper": "URL tapety",
"settings.wallpaperHint": "Pozadí přes celou nástěnku.",
"settings.customCss": "Vlastní CSS",
"settings.customCssHint": "Aplikuje se naposled — přebije proměnné motivu.",
"settings.notifications": "Notifikace",
"settings.notifyFanout": "Přepnutí monitorů a expirace domén/certifikátů se rozešlou všem nastaveným transportům.",
"settings.exportJson": "Export JSON",
"settings.import": "Import",
"settings.importHint": "Import umí Dash exporty plus konfigurace Homepage, Homarr a Dashy.",
"settings.saved": "uloženo",
"settings.sent": "odesláno",
"settings.failed": "selhalo",
"settings.testNotify": "Odeslat testovací notifikaci",
"settings.wallpaperPh": "https://… — prázdné = žádná",
"settings.importFailed": "Import selhal",
"service.editTitle": "Upravit službu",
"service.newTitle": "Přidat službu",
"service.desc": "Záložka na nástěnce — název, ikona, jedna nebo více URL.",
"service.newDesc": "Pojmenujte ji, dejte ikonu, přidejte jednu nebo více URL.",
"service.editDesc": "Upravte název, ikonu nebo URL.",
"service.icon": "URL ikony",
"service.iconHint": "URL nebo nahraný soubor; prázdné zobrazí dlaždici s písmenem.",
"service.upload": "Nahrát",
"service.urls": "URL",
"service.addUrl": "Přidat URL",
"service.labelPlaceholder": "štítek",
"service.removeUrl": "Odebrat URL",
"service.suggest": "návrh: {slug}.png",
"service.alsoMonitor": "Vytvořit i uptime monitor pro první URL",
"service.nameRequired": "Název je povinný",
"service.urlRequired": "Je potřeba alespoň jedna platná http(s) URL",
"service.saveFailed": "uložení selhalo",
"service.addService": "Přidat službu",
"widget.editTitle": "Upravit widget",
"widget.newTitle": "Přidat widget",
"widget.desc": "Živá data na nástěnce.",
"widget.newDesc": "Vyberte typ a nasměrujte na svou službu.",
"widget.editDesc": "Upravte nastavení widgetu.",
"widget.type": "Typ widgetu",
"widget.section": "Sekce",
"widget.pickOne": "Vyberte…",
"widget.pickType": "Vyberte typ widgetu",
"widget.requiredFields": "Vyplňte povinná pole",
"widget.defaultSection": "Widgety",
"widget.err": "chyba",
"widget.offline": "offline",
"widget.fetchFailed": "načtení selhalo",
"widget.queries": "{n} dotazů",
"widget.blocked": "{n} blokováno",
"widget.msAvg": "{n} ms prům.",
"widget.videos": "{n} videí",
"widget.blockedLbl": "blokováno",
"widget.photosLbl": "fotek",
"widget.embedHint": "Nastavte URL v konfiguraci widgetu.",
"renderer.localTime": "Místní čas",
"renderer.down": "Nedostupné",
"renderer.allGreen": "vše v pořádku",
"renderer.offline": "offline",
"renderer.needsAttention": "vyžaduje pozornost",
"renderer.downCount": "{n} nedostupných",
"renderer.entry": "položka",
"renderer.entries": "položek",
"renderer.dragSection": "Přesunout sekci",
"renderer.deleteSection": "Smazat sekci",
"renderer.live": "živě",
"monitors.title": "Monitory",
"monitors.subtitle": "HTTP, TCP, ping, DNS, klíčové slovo, JSON a push kontroly.",
"monitors.new": "Nový monitor",
"monitors.empty": "Zatím žádné monitory.",
"monitors.emptyHint": "Přidejte jeden pro sledování dostupnosti.",
"monitors.deleteConfirm": "Smazat monitor \"{name}\" a jeho historii?",
"monitors.checkNow": "Zkontrolovat nyní",
"monitors.pause": "Pozastavit",
"monitors.resume": "Obnovit",
"monitors.editTitle": "Upravit monitor",
"monitors.newTitle": "Nový monitor",
"monitors.desc": "Kontrola dostupnosti v intervalu.",
"monitors.method": "Metoda",
"monitors.keyword": "Klíčové slovo",
"monitors.keywordInvert": "Invertovat (down když nalezeno)",
"monitors.jsonPath": "JSON cesta",
"monitors.expected": "Očekávaná hodnota",
"monitors.record": "Záznam",
"monitors.dnsExpected": "Záznam obsahuje (volitelné)",
"monitors.pushHint": "Po uložení volají vaše úlohy zobrazenou push URL. Ztichne-li se na 2× interval, monitor přejde do down.",
"monitors.interval": "Interval (s)",
"monitors.timeout": "Timeout (s)",
"monitors.retries": "Opakování",
"monitors.failsBeforeDown": "Selhání před down",
"monitors.latencyWarn": "Varování latence (ms)",
"monitors.mute": "Ztišit notifikace",
"monitors.pushUrl": "Push URL",
"monitors.addMonitor": "Přidat monitor",
"monitors.nameRequired": "Název je povinný",
"monitors.urlRequired": "URL je povinná",
"monitors.hostnameRequired": "Hostname je povinný",
"monitors.saveFailed": "uložení selhalo",
"monitors.uptime24h": "24h dostupnost",
"monitors.avgLatency": "Prům. latence",
"monitors.lastCheck": "Poslední kontrola",
"monitors.latencyHistory": "Latence",
"monitors.uptimeHistory": "Historie dostupnosti",
"monitors.noHeartbeats": "Zatím žádné odezvy.",
"monitors.everyInterval": "každých {interval}s",
"monitors.status": "Stav",
"monitors.uptime30d": "Dostupnost 30d",
"monitors.ping": "Ping",
"monitors.every": "Každých",
"monitors.checksHint": "HTTP, TCP, ping, DNS, klíčové slovo, JSON cesta nebo push — kontroly běží na serveru ve vašem intervalu.",
"monitors.uptime": "Dostupnost",
"monitors.checks": "Kontroly",
"monitors.responseTime": "Doba odezvy",
"monitors.recentChecks": "Nedávné kontroly",
"monitors.noChecks": "Žádné kontroly v rozsahu.",
"monitors.noChecksBucket": "žádné kontroly",
"monitors.bucketChecks": "{status} ({n} kontrol)",
"domains.title": "Domény",
"domains.subtitle": "WHOIS, DNS a hlídání certifikátů.",
"domains.new": "Sledovat doménu",
"domains.empty": "Zatím žádné sledované domény.",
"domains.emptyHint": "Expirace, registrátor, DNS záznamy, SSL certifikáty a detekce poskytovatelů.",
"domains.domain": "Doména",
"domains.ssl": "SSL",
"domains.providers": "Poskytovatelé",
"domains.lookupError": "chyba dotazu",
"domains.refreshNow": "Obnovit nyní",
"domains.deleteConfirm": "Přestat sledovat \"{name}\"?",
"domains.refresh": "Překontrolovat",
"domains.expiresIn": "expiruje za {days}d",
"domains.expired": "expirováno",
"domains.certDays": "cert {days}d",
"domains.registrar": "Registrátor",
"domains.expires": "Expirace",
"domains.cert": "Certifikát",
"domains.nameservers": "Nameservery",
"domains.hosting": "Hosting",
"domains.subdomains": "Subdomény",
"domains.subdomainsHint": "Objevené přes Certificate Transparency logy; resolvováno přes DNS.",
"domains.scan": "Skenovat",
"domains.scanning": "Skenuji…",
"domains.noSubdomains": "Zatím žádné subdomény — spusťte sken.",
"domains.lastSeen": "naposledy viděna",
"domains.namePlaceholder": "example.com",
"domains.addTitle": "Sledovat doménu",
"domains.editTitle": "Upravit doménu",
"domains.addDesc": "WHOIS, DNS, TLS certifikát a host — obnoveno ve vašem intervalu.",
"domains.refreshEvery": "Obnovovat každých (hodin)",
"domains.alertDays": "Dní před expirací pro alert",
"domains.certDaysLbl": "Práh TLS alertu (dny)",
"domains.certDaysPlaceholder": "stejné jako expirace",
"domains.autoRenewLbl": "Auto-renew zapnutý u registrátora",
"domains.notesOptional": "Poznámky (volitelné)",
"domains.lookingUp": "Dotazuji…",
"domains.nameRequired": "Doména je povinná",
"domains.saveFailed": "uložení selhalo",
"domains.add": "Sledovat",
"domains.domainExpires": "Doména expiruje",
"domains.sslExpires": "SSL expiruje",
"domains.registration": "Registrace",
"domains.dns": "DNS",
"domains.tlsCert": "TLS certifikát",
"domains.host": "Host",
"domains.checkHistory": "Historie kontrol",
"domains.noSnapshots": "Zatím žádné snímky.",
"domains.created": "Vytvořeno",
"domains.updated": "Aktualizováno",
"domains.registrant": "Registrant",
"domains.country": "Země",
"domains.abuse": "Abuse",
"domains.statusLbl": "Stav",
"domains.location": "Lokalita",
"domains.isp": "ISP",
"domains.org": "Organizace",
"domains.checked": "Zkontrolováno",
"domains.dnsProvider": "DNS poskytovatel",
"domains.email": "E-mail",
"domains.issuer": "Vydavatel",
"domains.valid": "Platnost",
"domains.subject": "Subjekt",
"domains.key": "Klíč",
"domains.algorithm": "Algoritmus",
"domains.responseHeaders": "Hlavičky odpovědi ({n})",
"domains.transferLock": "transfer lock",
"domains.whoisPrivacy": "whois ochrana",
"domains.autoRenew": "auto-renew",
"domains.scanShort": "Sken",
"domains.noSubsFound": "Žádné subdomény nenalezeny — sken dotazuje certificate transparency.",
"domains.expPrefix": "exp",
"domains.sslPrefix": "ssl",
"systems.title": "Systémy",
"systems.subtitle": "Servery a PC reportující přes dash-agent.",
"systems.new": "Nový systém",
"systems.empty": "Zatím žádné systémy.",
"systems.emptyHintPre": "Přidejte jeden, poté spusťte",
"systems.emptyHintPost": "na hostiteli s vydaným tokenem.",
"systems.waiting": "čeká se na agenta…",
"systems.cores": "{n} jader",
"systems.newTitle": "Nový systém",
"systems.newDesc": "Pojmenujte hostitele, poté na něm spusťte dash-agent s vydaným tokenem.",
"systems.agentCmdFor": "Příkaz agenta pro",
"systems.tokenWarning": "Token identifikuje tento systém — nedávejte ho do veřejných repozitářů.",
"systems.done": "Hotovo",
"systems.copy": "Kopírovat",
"systems.deleteConfirm": "Smazat systém \"{name}\"?",
"systems.agentHint": "Spusťte agenta s tímto tokenem; statistiky dorazí sem.",
"systems.token": "Bearer token",
"systems.containers": "Kontejnery",
"systems.drives": "Disky",
"systems.zfs": "ZFS pooly",
"systems.gpu": "GPU",
"systems.cpu": "CPU",
"systems.mem": "Paměť",
"systems.disk": "Disk",
"systems.net": "Síť",
"systems.temp": "Teplota",
"systems.load": "Zátěž",
"systems.up": "up",
"systems.down": "down",
"systems.namePlaceholder": "nas",
"systems.addTitle": "Přidat systém",
"systems.addDesc": "Vytvoří bearer token — spusťte s ním dash-agent.",
"systems.notFound": "Systém nenalezen.",
"systems.usage": "Využití",
"systems.cpuPct": "CPU %",
"systems.memPct": "Paměť %",
"systems.netKbs": "Síť KB/s",
"systems.load1m": "Load 1m",
"systems.temperatures": "Teploty",
"systems.drivesSmart": "Disky (SMART)",
"systems.agentCmd": "Příkaz agenta",
"systems.neverSeen": "nikdy neviděn",
"systems.seenAt": "viděn {time}",
"systems.failing": "selhává",
"systems.uptime": "Doba běhu",
"incidents.title": "Incidenty",
"incidents.subtitle": "Automaticky otevřené při výpadcích monitorů, nebo vedené ručně.",
"incidents.new": "Nový incident",
"incidents.empty": "Žádné otevřené incidenty.",
"incidents.resolve": "Vyřešit",
"incidents.acknowledge": "Potvrdit",
"incidents.close": "Zavřít",
"incidents.reopen": "Znovu otevřít",
"incidents.deleteConfirm": "Smazat incident \"{title}\"?",
"incidents.openOnly": "Jen otevřené",
"incidents.showAll": "Zobrazit vše",
"incidents.noUpdates": "Zatím žádné aktualizace.",
"incidents.notePlaceholder": "Přidat poznámku…",
"incidents.post": "Odeslat",
"incidents.open": "otevřený",
"incidents.resolved": "vyřešený",
"incidents.editTitle": "Upravit incident",
"incidents.newTitle": "Nový incident",
"incidents.desc": "Ručně evidujte výpadek nebo degradaci.",
"incidents.severity": "Závažnost",
"incidents.sevMinor": "menší",
"incidents.sevMajor": "významný",
"incidents.sevCritical": "kritický",
"incidents.status": "Stav",
"incidents.monitor": "Monitor",
"incidents.bodyLabel": "Podrobnosti",
"incidents.noteOptional": "Poznámka (volitelná)",
"incidents.titlePlaceholder": "NAS offline",
"incidents.noteFirst": "Poprvé pozorováno…",
"incidents.createError": "Incident se nepodařilo vytvořit.",
"statusPages.title": "Stavové stránky",
"statusPages.publicAt": "Veřejné živé náhledy na",
"statusPages.new": "Nová stránka",
"statusPages.empty": "Zatím žádné stavové stránky.",
"statusPages.open": "Otevřít veřejnou stránku",
"statusPages.deleteConfirm": "Smazat stavovou stránku \"{title}\"?",
"statusPages.createError": "Nepodařilo se vytvořit — slug může být obsazený.",
"statusPages.newTitle": "Nová stavová stránka",
"statusPages.newDesc": "Veřejný živý náhled nad vybranými monitory.",
"statusPages.monitors": "Monitory",
"statusPages.services": "Služby",
"statusPages.noMonitors": "Zatím žádné monitory — prázdný výběr zobrazí vše.",
"statusPages.allHint": "Nechte vše nezaškrtnuté pro zahrnutí všech aktivních monitorů.",
"statusPages.slugPlaceholder": "auto",
"statusPages.titlePlaceholder": "Stav homelab",
"maintenance.title": "Okna údržby",
"maintenance.subtitle": "Aktivní okna ztišují alerty + auto-incidenty a označují pokryté monitory.",
"maintenance.schedule": "Naplánovat",
"maintenance.empty": "Žádná naplánovaná údržba.",
"maintenance.deleteConfirm": "Smazat okno \"{title}\"?",
"maintenance.active": "aktivní",
"maintenance.scheduled": "naplánované",
"maintenance.dialogTitle": "Naplánovat údržbu",
"maintenance.dialogDesc": "Potlačí alerty a auto-incidenty po dobu okna.",
"maintenance.starts": "Začátek",
"maintenance.ends": "Konec",
"maintenance.titlePlaceholder": "Upgrade sítě",
"maintenance.createError": "Neplatné okno — zkontrolujte časy.",
"statusPublic.operational": "Všechny systémy v provozu",
"statusPublic.degraded": "Částečný výpadek",
"statusPublic.outage": "Velký výpadek",
"statusPublic.underMaintenance": "Probíhá údržba",
"statusPublic.poweredBy": "Běží na Dash",
"statusPublic.updated": "Aktualizováno {time}",
"statusPublic.noMonitors": "Tato stránka zatím nemá monitory.",
"statusPublic.uptime90": "90denní dostupnost",
"statusPublic.services": "Služby",
"statusPublic.systems": "Systémy",
"statusPublic.maintenance": "Údržba",
"statusPublic.incidents": "Incidenty",
"statusPublic.checking": "Kontroluji…",
"statusPublic.notFound": "Stavová stránka nenalezena.",
"statusPublic.footer": "běží na dash",
};
+388
View File
@@ -20,8 +20,396 @@ export const en = {
"common.delete": "Delete",
"common.cancel": "Cancel",
"common.save": "Save",
"common.create": "Create",
"common.creating": "Creating…",
"common.saving": "Saving…",
"common.test": "Test",
"common.loading": "Loading…",
"common.name": "Name",
"common.title": "Title",
"common.description": "Description",
"common.slug": "Slug",
"common.url": "URL",
"common.type": "Type",
"common.hostname": "Hostname",
"common.port": "Port",
"common.optional": "optional",
"common.enabled": "Enabled",
"common.refresh": "Refresh",
"common.never": "never",
"common.all": "All",
"common.reset": "Reset",
"common.close": "Close",
"auth.setupTitle": "Create your admin account",
"auth.setupHint": "First user becomes the admin. Local account only — no external identity.",
"auth.signInTitle": "Sign in",
"auth.signInHint": "This Dash instance requires authentication.",
"auth.username": "Username or email",
"auth.password": "Password",
"auth.createAccount": "Create account",
"auth.signIn": "Sign in",
"auth.invalid": "Invalid username or password.",
"auth.rateLimited": "Too many attempts — wait a minute.",
"auth.section": "Authentication",
"auth.offHint": "Anyone who can reach this instance has full access. Enable to require sign-in.",
"auth.enable": "Enable authentication",
"auth.onHint": "Signed in as",
"auth.signOut": "Sign out",
"auth.disable": "Disable authentication",
"auth.disabledNote": "Auth is off — the dashboard is open to your network.",
"boards.title": "Boards",
"boards.manage": "Manage boards…",
"boards.newPlaceholder": "New board name",
"board.empty": "No sections yet.",
"board.emptyHint": "Add a service or a widget to get started.",
"board.addSection": "Add section",
"board.sectionNamePlaceholder": "Section name",
"board.deleteSectionConfirm": "Delete section \"{name}\" and its {count} items?",
"palette.title": "Command palette",
"palette.placeholder": "Search services and actions…",
"palette.noResults": "No matches.",
"palette.pages": "Pages",
"palette.services": "Services",
"palette.actions": "Actions",
"palette.goTo": "Go to {page}",
"palette.openSettings": "Open settings",
"palette.themeSwitch": "Switch to {theme} theme",
"palette.renderer": "Renderer: {name}",
"palette.exportJson": "Export board JSON",
"palette.hintNew": "new",
"palette.hintActive": "active",
"settings.title": "Settings",
"settings.desc": "Board layout, theme, and backup.",
"settings.theme": "Theme",
"settings.language": "Language",
"settings.dark": "Dark",
"settings.light": "Light",
"settings.renderer": "Board renderer",
"settings.appearance": "Appearance",
"settings.accent": "Accent color",
"settings.accentHint": "Any CSS color — hex, hsl, named.",
"settings.wallpaper": "Wallpaper URL",
"settings.wallpaperHint": "Full-bleed background image behind the board.",
"settings.customCss": "Custom CSS",
"settings.customCssHint": "Injected last — wins over theme variables.",
"settings.notifications": "Notifications",
"settings.notifyFanout": "Monitor flips and domain/certificate expiry fan out to every configured transport.",
"settings.exportJson": "Export JSON",
"settings.import": "Import",
"settings.importHint": "Import accepts Dash exports plus Homepage, Homarr, and Dashy configs.",
"settings.saved": "saved",
"settings.sent": "sent",
"settings.failed": "failed",
"settings.testNotify": "Send test notification",
"settings.wallpaperPh": "https://… — blank for none",
"settings.importFailed": "Import failed",
"service.editTitle": "Edit service",
"service.newTitle": "Add service",
"service.desc": "A bookmark on the board — name, icon, one or more URLs.",
"service.newDesc": "Name it, give it an icon, add one or more URLs.",
"service.editDesc": "Update name, icon, or launch URLs.",
"service.icon": "Icon URL",
"service.iconHint": "URL or uploaded file; blank shows a letter tile.",
"service.upload": "Upload",
"service.urls": "URLs",
"service.addUrl": "Add URL",
"service.labelPlaceholder": "label",
"service.removeUrl": "Remove URL",
"service.suggest": "suggest: {slug}.png",
"service.alsoMonitor": "Also create an uptime monitor for the first URL",
"service.nameRequired": "Name is required",
"service.urlRequired": "At least one valid http(s) URL is required",
"service.saveFailed": "save failed",
"service.addService": "Add service",
"widget.editTitle": "Edit widget",
"widget.newTitle": "Add widget",
"widget.desc": "Live data on the board.",
"widget.newDesc": "Pick a type and point it at your service.",
"widget.editDesc": "Update widget settings.",
"widget.type": "Widget type",
"widget.section": "Section",
"widget.pickOne": "Pick one…",
"widget.pickType": "Pick a widget type",
"widget.requiredFields": "Fill in the required fields",
"widget.defaultSection": "Widgets",
"widget.err": "err",
"widget.offline": "offline",
"widget.fetchFailed": "fetch failed",
"widget.queries": "{n} queries",
"widget.blocked": "{n} blocked",
"widget.msAvg": "{n} ms avg",
"widget.videos": "{n} videos",
"widget.blockedLbl": "blocked",
"widget.photosLbl": "photos",
"widget.embedHint": "Set a URL in the widget config.",
"renderer.localTime": "Local time",
"renderer.down": "Down",
"renderer.allGreen": "all green",
"renderer.offline": "offline",
"renderer.needsAttention": "needs attention",
"renderer.downCount": "{n} down",
"renderer.entry": "entry",
"renderer.entries": "entries",
"renderer.dragSection": "Drag section",
"renderer.deleteSection": "Delete section",
"renderer.live": "live",
"monitors.title": "Monitors",
"monitors.subtitle": "HTTP, TCP, ping, DNS, keyword, JSON, and push checks.",
"monitors.new": "New monitor",
"monitors.empty": "No monitors yet.",
"monitors.emptyHint": "Add one to start checking uptime.",
"monitors.deleteConfirm": "Delete monitor \"{name}\" and its history?",
"monitors.checkNow": "Check now",
"monitors.pause": "Pause",
"monitors.resume": "Resume",
"monitors.editTitle": "Edit monitor",
"monitors.newTitle": "New monitor",
"monitors.desc": "Uptime check on a schedule.",
"monitors.method": "Method",
"monitors.keyword": "Keyword",
"monitors.keywordInvert": "Invert (down when found)",
"monitors.jsonPath": "JSON path",
"monitors.expected": "Expected",
"monitors.record": "Record",
"monitors.dnsExpected": "Expected record contains (optional)",
"monitors.pushHint": "After saving, your jobs call the shown push URL on a schedule. If it goes quiet for 2× the interval, the monitor flips down.",
"monitors.interval": "Interval (s)",
"monitors.timeout": "Timeout (s)",
"monitors.retries": "Retries",
"monitors.failsBeforeDown": "Fails before down",
"monitors.latencyWarn": "Latency warn (ms)",
"monitors.mute": "Mute alerts",
"monitors.pushUrl": "Push URL",
"monitors.addMonitor": "Add monitor",
"monitors.nameRequired": "Name is required",
"monitors.urlRequired": "URL is required",
"monitors.hostnameRequired": "Hostname is required",
"monitors.saveFailed": "save failed",
"monitors.uptime24h": "24h uptime",
"monitors.avgLatency": "Avg latency",
"monitors.lastCheck": "Last check",
"monitors.latencyHistory": "Latency",
"monitors.uptimeHistory": "Uptime history",
"monitors.noHeartbeats": "No heartbeats yet.",
"monitors.everyInterval": "every {interval}s",
"monitors.status": "Status",
"monitors.uptime30d": "Uptime 30d",
"monitors.ping": "Ping",
"monitors.every": "Every",
"monitors.checksHint": "HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.",
"monitors.uptime": "Uptime",
"monitors.checks": "Checks",
"monitors.responseTime": "Response time",
"monitors.recentChecks": "Recent checks",
"monitors.noChecks": "No checks in range.",
"monitors.noChecksBucket": "no checks",
"monitors.bucketChecks": "{status} ({n} checks)",
"domains.title": "Domains",
"domains.subtitle": "WHOIS, DNS, and certificate watch.",
"domains.new": "Track domain",
"domains.empty": "No domains tracked yet.",
"domains.emptyHint": "Expiry, registrar, DNS records, SSL certificates, and provider detection.",
"domains.domain": "Domain",
"domains.ssl": "SSL",
"domains.providers": "Providers",
"domains.lookupError": "lookup error",
"domains.refreshNow": "Refresh now",
"domains.deleteConfirm": "Stop tracking \"{name}\"?",
"domains.refresh": "Re-check",
"domains.expiresIn": "expires in {days}d",
"domains.expired": "expired",
"domains.certDays": "cert {days}d",
"domains.registrar": "Registrar",
"domains.expires": "Expires",
"domains.cert": "Certificate",
"domains.nameservers": "Nameservers",
"domains.hosting": "Hosting",
"domains.subdomains": "Subdomains",
"domains.subdomainsHint": "Discovered via Certificate Transparency logs; resolved over DNS.",
"domains.scan": "Scan now",
"domains.scanning": "Scanning…",
"domains.noSubdomains": "No subdomains discovered yet — run a scan.",
"domains.lastSeen": "last seen",
"domains.namePlaceholder": "example.com",
"domains.addTitle": "Track a domain",
"domains.editTitle": "Edit domain",
"domains.addDesc": "WHOIS, DNS, TLS certificate, and host info — refreshed on your interval.",
"domains.refreshEvery": "Refresh every (hours)",
"domains.alertDays": "Alert days before expiry",
"domains.certDaysLbl": "TLS alert threshold (days)",
"domains.certDaysPlaceholder": "same as expiry",
"domains.autoRenewLbl": "Auto-renew enabled at the registrar",
"domains.notesOptional": "Notes (optional)",
"domains.lookingUp": "Looking up…",
"domains.nameRequired": "Domain is required",
"domains.saveFailed": "save failed",
"domains.add": "Track",
"domains.domainExpires": "Domain expires",
"domains.sslExpires": "SSL expires",
"domains.registration": "Registration",
"domains.dns": "DNS",
"domains.tlsCert": "TLS certificate",
"domains.host": "Host",
"domains.checkHistory": "Check history",
"domains.noSnapshots": "No snapshots yet.",
"domains.created": "Created",
"domains.updated": "Updated",
"domains.registrant": "Registrant",
"domains.country": "Country",
"domains.abuse": "Abuse",
"domains.statusLbl": "Status",
"domains.location": "Location",
"domains.isp": "ISP",
"domains.org": "Org",
"domains.checked": "Checked",
"domains.dnsProvider": "DNS provider",
"domains.email": "Email",
"domains.issuer": "Issuer",
"domains.valid": "Valid",
"domains.subject": "Subject",
"domains.key": "Key",
"domains.algorithm": "Algorithm",
"domains.responseHeaders": "Response headers ({n})",
"domains.transferLock": "transfer-locked",
"domains.whoisPrivacy": "whois privacy",
"domains.autoRenew": "auto-renew",
"domains.scanShort": "Scan",
"domains.noSubsFound": "No subdomains discovered — scan queries certificate transparency.",
"domains.expPrefix": "exp",
"domains.sslPrefix": "ssl",
"systems.title": "Systems",
"systems.subtitle": "Servers and PCs reporting via dash-agent.",
"systems.new": "New system",
"systems.empty": "No systems yet.",
"systems.emptyHintPre": "Add one, then run",
"systems.emptyHintPost": "on the host with the issued token.",
"systems.waiting": "waiting for agent…",
"systems.cores": "{n} cores",
"systems.newTitle": "New system",
"systems.newDesc": "Name the host, then run dash-agent on it with the issued token.",
"systems.agentCmdFor": "Agent command for",
"systems.tokenWarning": "The token identifies this system — keep it out of public repos.",
"systems.done": "Done",
"systems.copy": "Copy",
"systems.deleteConfirm": "Delete system \"{name}\"?",
"systems.agentHint": "Run the agent with this token; stats land here.",
"systems.token": "Bearer token",
"systems.containers": "Containers",
"systems.drives": "Drives",
"systems.zfs": "ZFS pools",
"systems.gpu": "GPU",
"systems.cpu": "CPU",
"systems.mem": "Memory",
"systems.disk": "Disk",
"systems.net": "Network",
"systems.temp": "Temp",
"systems.load": "Load",
"systems.up": "up",
"systems.down": "down",
"systems.namePlaceholder": "nas",
"systems.addTitle": "Add a system",
"systems.addDesc": "Creates a bearer token — run dash-agent with it.",
"systems.notFound": "System not found.",
"systems.usage": "Usage",
"systems.cpuPct": "CPU %",
"systems.memPct": "Memory %",
"systems.netKbs": "Network KB/s",
"systems.load1m": "Load 1m",
"systems.temperatures": "Temperatures",
"systems.drivesSmart": "Drives (SMART)",
"systems.agentCmd": "Agent command",
"systems.neverSeen": "never seen",
"systems.seenAt": "seen {time}",
"systems.failing": "failing",
"systems.uptime": "Uptime",
"incidents.title": "Incidents",
"incidents.subtitle": "Auto-opened on monitor outages, or tracked manually.",
"incidents.new": "New incident",
"incidents.empty": "No open incidents.",
"incidents.resolve": "Resolve",
"incidents.acknowledge": "Acknowledge",
"incidents.close": "Close",
"incidents.reopen": "Reopen",
"incidents.deleteConfirm": "Delete incident \"{title}\"?",
"incidents.openOnly": "Open only",
"incidents.showAll": "Show all",
"incidents.noUpdates": "No updates yet.",
"incidents.notePlaceholder": "Add a note…",
"incidents.post": "Post",
"incidents.open": "open",
"incidents.resolved": "resolved",
"incidents.editTitle": "Edit incident",
"incidents.newTitle": "New incident",
"incidents.desc": "Track an outage or degradation manually.",
"incidents.severity": "Severity",
"incidents.sevMinor": "minor",
"incidents.sevMajor": "major",
"incidents.sevCritical": "critical",
"incidents.status": "Status",
"incidents.monitor": "Monitor",
"incidents.bodyLabel": "Details",
"incidents.noteOptional": "Note (optional)",
"incidents.titlePlaceholder": "NAS offline",
"incidents.noteFirst": "First observed…",
"incidents.createError": "Could not create incident.",
"statusPages.title": "Status pages",
"statusPages.publicAt": "Public live views at",
"statusPages.new": "New page",
"statusPages.empty": "No status pages yet.",
"statusPages.open": "Open public page",
"statusPages.deleteConfirm": "Delete status page \"{title}\"?",
"statusPages.createError": "Could not create — slug may be taken.",
"statusPages.newTitle": "New status page",
"statusPages.newDesc": "Public live view over selected monitors.",
"statusPages.monitors": "Monitors",
"statusPages.services": "Services",
"statusPages.noMonitors": "No monitors yet — empty selection shows all.",
"statusPages.allHint": "Leave all unchecked to include every active monitor.",
"statusPages.slugPlaceholder": "auto",
"statusPages.titlePlaceholder": "Homelab status",
"maintenance.title": "Maintenance windows",
"maintenance.subtitle": "Active windows mute alerts + auto-incidents and mark covered monitors.",
"maintenance.schedule": "Schedule",
"maintenance.empty": "No maintenance scheduled.",
"maintenance.deleteConfirm": "Delete window \"{title}\"?",
"maintenance.active": "active",
"maintenance.scheduled": "scheduled",
"maintenance.dialogTitle": "Schedule maintenance",
"maintenance.dialogDesc": "Suppresses alerts and auto-incidents for the window.",
"maintenance.starts": "Starts",
"maintenance.ends": "Ends",
"maintenance.titlePlaceholder": "Network upgrade",
"maintenance.createError": "Invalid window — check times.",
"statusPublic.operational": "All systems operational",
"statusPublic.degraded": "Partial outage",
"statusPublic.outage": "Major outage",
"statusPublic.underMaintenance": "Under maintenance",
"statusPublic.poweredBy": "Powered by Dash",
"statusPublic.updated": "Updated {time}",
"statusPublic.noMonitors": "No monitors on this page yet.",
"statusPublic.uptime90": "90-day uptime",
"statusPublic.services": "Services",
"statusPublic.systems": "Systems",
"statusPublic.maintenance": "Maintenance",
"statusPublic.incidents": "Incidents",
"statusPublic.checking": "Checking…",
"statusPublic.notFound": "Status page not found.",
"statusPublic.footer": "powered by dash",
} as const;
export type Key = keyof typeof en;
+21 -7
View File
@@ -1,13 +1,27 @@
import { en, type Key } from "@/i18n/en";
import { cs } from "@/i18n/cs";
export type { Key } from "@/i18n/en";
// i18n seam: dictionaries are plain objects keyed by dotted names. `t` is a
// typed lookup that falls back to the key itself, so a missing translation
// renders the key — loud in dev, never a crash. Locales beyond `en` plug in
// here later (settings key + lazy import); nothing else changes.
const dict: Record<Key, string> = en;
export const LOCALES = { en: "English", cs: "Čeština" } as const;
export type Locale = keyof typeof LOCALES;
export function t(key: Key): string {
return dict[key] ?? key;
// i18n seam: dictionaries are plain objects keyed by dotted names. `t` is a
// typed lookup that falls back to English, then to the key itself — a missing
// translation is loud in dev, never a crash. The store calls setLocale when
// the `locale` setting loads/changes; components re-render on prefs updates.
const dicts: Record<Locale, Partial<Record<Key, string>>> = { en, cs };
let active: Locale = "en";
export function setLocale(l: string) {
active = l === "cs" ? "cs" : "en";
document.documentElement.lang = active;
}
export function t(key: Key, params?: Record<string, string | number>): string {
let s: string = dicts[active][key] ?? en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) s = s.replaceAll(`{${k}}`, String(v));
}
return s;
}
+6
View File
@@ -16,3 +16,9 @@ createRoot(root).render(
<App />
</StrictMode>,
);
// PWA: register the service worker only in production builds — in dev it would
// fight Vite's HMR by caching modules.
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"));
}
+88 -46
View File
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useState } from "react";
import { Link } from "wouter";
import { ArrowLeft, Pause, Play, RefreshCw, Shield, Trash2 } from "lucide-react";
import { api } from "@/api";
import type { DomainCheck, DomainView } from "@/types";
import type { DomainCheck, DomainView, Subdomain } from "@/types";
import { DomainDialog } from "@/components/DomainDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
function Row({ k, v, mono }: { k: string; v: React.ReactNode; mono?: boolean }) {
if (v === undefined || v === null || v === "") return null;
@@ -34,28 +35,32 @@ function fmtDate(iso: string | null | undefined) {
export function DomainDetailPage({ id }: { id: string }) {
const [d, setD] = useState<DomainView | null>(null);
const [checks, setChecks] = useState<DomainCheck[]>([]);
const [subs, setSubs] = useState<Subdomain[]>([]);
const [dlgOpen, setDlgOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [subScanning, setSubScanning] = useState(false);
const load = useCallback(async () => {
const [{ data: dv }, { data: ck }] = await Promise.all([
const [{ data: dv }, { data: ck }, { data: sb }] = await Promise.all([
api.GET("/api/domains/{id}", { params: { path: { id } } }),
api.GET("/api/domains/{id}/checks", { params: { path: { id } } }),
api.GET("/api/domains/{id}/subdomains", { params: { path: { id } } }),
]);
if (dv) setD(dv);
if (ck) setChecks(ck);
if (sb) setSubs(sb);
}, [id]);
useEffect(() => { void load(); }, [load]);
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
if (!d) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const expiring = d.expiring || (d.daysUntilExpiry ?? Infinity) < 0;
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/domains" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Domains
<ArrowLeft size={12} /> {t("nav.domains")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -66,9 +71,9 @@ export function DomainDetailPage({ id }: { id: string }) {
<p className="mt-0.5 flex items-center gap-2 font-mono text-[11px] text-text-faint">
{d.tld ? `.${d.tld}` : ""}
{d.dnssec === "signed" && <span className="flex items-center gap-1 text-up"><Shield size={10} /> dnssec</span>}
{d.transferLock ? <span>transfer-locked</span> : null}
{d.privacyEnabled ? <span>whois privacy</span> : null}
{d.autoRenew ? <span>auto-renew</span> : null}
{d.transferLock ? <span>{t("domains.transferLock")}</span> : null}
{d.privacyEnabled ? <span>{t("domains.whoisPrivacy")}</span> : null}
{d.autoRenew ? <span>{t("domains.autoRenew")}</span> : null}
</p>
</div>
</div>
@@ -79,16 +84,16 @@ export function DomainDetailPage({ id }: { id: string }) {
void api.POST("/api/domains/{id}/refresh", { params: { path: { id } } })
.then(load).finally(() => setRefreshing(false));
}}>
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> Refresh
<RefreshCw size={12} className={cn(refreshing && "animate-spin")} /> {t("common.refresh")}
</Button>
<Button variant="outline" size="sm"
onClick={() => void api.PATCH("/api/domains/{id}", { params: { path: { id } }, body: { active: !d.active } }).then(load)}>
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? "Pause" : "Resume"}
{d.active ? <Pause size={12} /> : <Play size={12} />} {d.active ? t("monitors.pause") : t("monitors.resume")}
</Button>
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>{t("common.edit")}</Button>
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Stop tracking "${d.name}"?`)) {
if (confirm(t("domains.deleteConfirm", { name: d.name ?? "" }))) {
void api.DELETE("/api/domains/{id}", { params: { path: { id } } }).then(() => { location.href = "/domains"; });
}
}}>
@@ -105,86 +110,86 @@ export function DomainDetailPage({ id }: { id: string }) {
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Domain expires</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.domainExpires")}</div>
<div className={cn("mt-1 font-mono text-[17px] font-medium", expiring && "text-down")}>
{d.daysUntilExpiry !== null && d.daysUntilExpiry !== undefined
? (d.daysUntilExpiry < 0 ? "expired" : `${d.daysUntilExpiry}d`)
? (d.daysUntilExpiry < 0 ? t("domains.expired") : `${d.daysUntilExpiry}d`)
: "—"}
</div>
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.expiryDate)}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">SSL expires</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.sslExpires")}</div>
<div className={cn("mt-1 font-mono text-[17px] font-medium", d.sslExpiring && "text-down")}>
{d.sslDaysUntilExpiry !== null && d.sslDaysUntilExpiry !== undefined
? (d.sslDaysUntilExpiry < 0 ? "expired" : `${d.sslDaysUntilExpiry}d`)
? (d.sslDaysUntilExpiry < 0 ? t("domains.expired") : `${d.sslDaysUntilExpiry}d`)
: "—"}
</div>
<div className="font-mono text-[10px] text-text-faint">{fmtDate(d.sslValidTo)}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Registrar</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.registrar")}</div>
<div className="mt-1 truncate text-[13px] font-medium">{d.registrarName || "—"}</div>
<div className="font-mono text-[10px] text-text-faint">{d.caProvider || "—"}</div>
</div>
<div className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Providers</div>
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("domains.providers")}</div>
<div className="mt-1 truncate text-[13px] font-medium">{d.dnsProvider || "—"}</div>
<div className="font-mono text-[10px] text-text-faint">{d.hostingProvider || d.emailProvider || "—"}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 max-[820px]:grid-cols-1">
<Section title="Registration">
<Row k="Created" v={fmtDate(d.creationDate)} />
<Row k="Updated" v={fmtDate(d.updatedDate)} />
<Row k="Registrar" v={d.registrarName} />
<Section title={t("domains.registration")}>
<Row k={t("domains.created")} v={fmtDate(d.creationDate)} />
<Row k={t("domains.updated")} v={fmtDate(d.updatedDate)} />
<Row k={t("domains.registrar")} v={d.registrarName} />
<Row k="IANA ID" v={d.registrarId} mono />
<Row k="Registry ID" v={d.registryDomainId} mono />
<Row k="Registrant" v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
<Row k="Country" v={d.registrantCountry} />
<Row k="Abuse" v={d.abuseEmail} mono />
{d.statuses && d.statuses.length > 0 ? <Row k="Status" v={d.statuses.join(", ")} mono /> : null}
<Row k={t("domains.registrant")} v={[d.registrantName, d.registrantOrg].filter(Boolean).join(" / ")} />
<Row k={t("domains.country")} v={d.registrantCountry} />
<Row k={t("domains.abuse")} v={d.abuseEmail} mono />
{d.statuses && d.statuses.length > 0 ? <Row k={t("domains.statusLbl")} v={d.statuses.join(", ")} mono /> : null}
</Section>
<Section title="DNS">
<Section title={t("domains.dns")}>
{d.nameServers?.map((n) => <Row key={n} k="NS" v={n} mono />)}
{d.mxRecords?.map((m) => <Row key={m} k="MX" v={m} mono />)}
{d.ipv4?.map((a) => <Row key={a} k="A" v={a} mono />)}
{d.ipv6?.map((a) => <Row key={a} k="AAAA" v={a} mono />)}
{d.cname ? <Row k="CNAME" v={d.cname} mono /> : null}
{d.txtRecords?.slice(0, 4).map((t, i) => <Row key={i} k="TXT" v={t.length > 60 ? t.slice(0, 60) + "…" : t} mono />)}
{d.dnsProvider ? <Row k="DNS provider" v={d.dnsProvider} /> : null}
{d.emailProvider ? <Row k="Email" v={d.emailProvider} /> : null}
{d.dnsProvider ? <Row k={t("domains.dnsProvider")} v={d.dnsProvider} /> : null}
{d.emailProvider ? <Row k={t("domains.email")} v={d.emailProvider} /> : null}
</Section>
<Section title="TLS certificate">
<Row k="Issuer" v={d.sslIssuer} />
<Section title={t("domains.tlsCert")}>
<Row k={t("domains.issuer")} v={d.sslIssuer} />
<Row k="CA" v={d.caProvider} />
<Row k="Valid" v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
<Row k="Subject" v={d.sslSubject} mono />
<Row k="Key" v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
<Row k="Algorithm" v={d.sslSigAlgo} mono />
<Row k={t("domains.valid")} v={`${fmtDate(d.sslValidFrom)} → ${fmtDate(d.sslValidTo)}`} mono />
<Row k={t("domains.subject")} v={d.sslSubject} mono />
<Row k={t("domains.key")} v={d.sslKeySize ? `${d.sslKeySize} bit` : ""} mono />
<Row k={t("domains.algorithm")} v={d.sslSigAlgo} mono />
{d.sslAltNames && d.sslAltNames.length > 0 ? (
<Row k="SANs" v={d.sslAltNames.slice(0, 5).join(", ") + (d.sslAltNames.length > 5 ? ` +${d.sslAltNames.length - 5}` : "")} mono />
) : null}
<Row k="SHA-256" v={d.sslFingerprint ? d.sslFingerprint.slice(0, 29) + "…" : ""} mono />
</Section>
<Section title="Host">
<Row k="Location" v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
<Row k="ISP" v={d.hostIsp} />
<Row k="Org" v={d.hostOrg} />
<Section title={t("domains.host")}>
<Row k={t("domains.location")} v={[d.hostCity, d.hostRegion, d.hostCountry].filter(Boolean).join(", ")} />
<Row k={t("domains.isp")} v={d.hostIsp} />
<Row k={t("domains.org")} v={d.hostOrg} />
<Row k="AS" v={d.hostAs} mono />
<Row k="Hosting" v={d.hostingProvider} />
<Row k="Checked" v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
<Row k={t("domains.hosting")} v={d.hostingProvider} />
<Row k={t("domains.checked")} v={d.lastChecked ? new Date(d.lastChecked).toLocaleString() : "—"} mono />
</Section>
</div>
{Object.keys(d.headers ?? {}).length > 0 ? (
<details className="mt-3 overflow-hidden rounded-[10px] border border-border bg-surface">
<summary className="cursor-pointer px-4 py-2.5 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
Response headers ({Object.keys(d.headers ?? {}).length})
{t("domains.responseHeaders", { n: Object.keys(d.headers ?? {}).length })}
</summary>
<div className="border-t border-border px-4 py-2">
{Object.entries(d.headers ?? {}).map(([k, v]) => (
@@ -197,20 +202,57 @@ export function DomainDetailPage({ id }: { id: string }) {
</details>
) : null}
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Check history</h2>
<div className="mb-2 mt-6 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">
{t("domains.subdomains")}{subs.length > 0 ? ` (${subs.length})` : ""}
</h2>
<Button variant="ghost" size="sm" disabled={subScanning}
onClick={() => {
setSubScanning(true);
void api.POST("/api/domains/{id}/subdomains/refresh", { params: { path: { id } } })
.then(({ data }) => { if (data) setSubs(data); })
.finally(() => setSubScanning(false));
}}>
<RefreshCw size={11} className={cn(subScanning && "animate-spin")} /> {t("domains.scanShort")}
</Button>
</div>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
{subs.map((s) => (
<tr key={s.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px]">{s.name}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">
{(s.ips ?? []).join(", ") || "—"}
</td>
<td className="px-4 py-2 text-right font-mono text-[10.5px] text-text-faint">
{fmtDate(s.lastSeen)}
</td>
</tr>
))}
{subs.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">
{t("domains.noSubsFound")}
</td></tr>
)}
</tbody>
</table>
</div>
<h2 className="mb-2 mt-6 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("domains.checkHistory")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
{checks.map((c) => (
<tr key={c.id} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[10.5px] text-text-faint">{new Date(c.checkedAt ?? "").toLocaleString()}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">exp {fmtDate(c.expiryDate)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">ssl {fmtDate(c.sslValidTo)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{t("domains.expPrefix")} {fmtDate(c.expiryDate)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{t("domains.sslPrefix")} {fmtDate(c.sslValidTo)}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-faint">{(c.ipv4 ?? []).join(", ")}</td>
</tr>
))}
{checks.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">No snapshots yet.</td></tr>
<tr><td className="px-4 py-6 text-center text-text-faint">{t("domains.noSnapshots")}</td></tr>
)}
</tbody>
</table>
+18 -18
View File
@@ -6,11 +6,12 @@ import type { DomainView } from "@/types";
import { DomainDialog } from "@/components/DomainDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
function daysLabel(days: number | null | undefined, warn?: boolean) {
if (days === null || days === undefined) return "—";
const cls = warn || days < 0 ? "text-down" : days <= 30 ? "text-amber-500" : "text-text-dim";
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? "expired" : `${days}d`}</span>;
return <span className={cn("font-mono text-[11px]", cls)}>{days < 0 ? t("domains.expired") : `${days}d`}</span>;
}
export function DomainsPage() {
@@ -38,19 +39,19 @@ export function DomainsPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Domains</h1>
<p className="text-[12px] text-text-faint">WHOIS, DNS, and certificate watch.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("domains.title")}</h1>
<p className="text-[12px] text-text-faint">{t("domains.subtitle")}</p>
</div>
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> Track domain
<Plus size={13} strokeWidth={2.2} /> {t("domains.new")}
</Button>
</div>
{loaded && domains.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No domains tracked yet.</p>
<p className="text-[13px] text-text-dim">{t("domains.empty")}</p>
<p className="mt-1 text-[11.5px] text-text-faint">
Expiry, registrar, DNS records, SSL certificates, and provider detection.
{t("domains.emptyHint")}
</p>
</div>
) : (
@@ -58,11 +59,11 @@ export function DomainsPage() {
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
<th className="px-4 py-2.5 font-medium">Domain</th>
<th className="px-4 py-2.5 font-medium">Registrar</th>
<th className="px-4 py-2.5 font-medium">Expires</th>
<th className="px-4 py-2.5 font-medium">SSL</th>
<th className="px-4 py-2.5 font-medium">Providers</th>
<th className="px-4 py-2.5 font-medium">{t("domains.domain")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.registrar")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.expires")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.ssl")}</th>
<th className="px-4 py-2.5 font-medium">{t("domains.providers")}</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
@@ -76,7 +77,7 @@ export function DomainsPage() {
) : null}
<Link href={`/domains/${d.id}`} className="hover:underline">{d.name}</Link>
{d.lookupError ? (
<span className="font-mono text-[10px] text-down" title={d.lookupError}>lookup error</span>
<span className="font-mono text-[10px] text-down" title={d.lookupError}>{t("domains.lookupError")}</span>
) : null}
</span>
</td>
@@ -88,7 +89,7 @@ export function DomainsPage() {
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="icon" aria-label="Refresh now" disabled={refreshing === d.id}
<Button variant="outline" size="icon" aria-label={t("domains.refreshNow")} disabled={refreshing === d.id}
onClick={() => {
if (!d.id) return;
setRefreshing(d.id);
@@ -97,20 +98,19 @@ export function DomainsPage() {
}}>
<RefreshCw size={12} className={cn(refreshing === d.id && "animate-spin")} />
</Button>
<Button variant="outline" size="icon" aria-label={d.active ? "Pause" : "Resume"}
<Button variant="outline" size="icon" aria-label={d.active ? t("monitors.pause") : t("monitors.resume")}
onClick={() => void act(d.id, () => api.PATCH("/api/domains/{id}", { params: { path: { id: d.id! } }, body: { active: !d.active } }))}>
{d.active ? <Pause size={12} /> : <Play size={12} />}
</Button>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Stop tracking "${d.name}"?`)) {
if (confirm(t("domains.deleteConfirm", { name: d.name ?? "" }))) {
void act(d.id, () => api.DELETE("/api/domains/{id}", { params: { path: { id: d.id! } } }));
}
}}>
<Trash2 size={12} />
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>
Edit
<Button variant="outline" size="sm" onClick={() => { setEditing(d); setDlgOpen(true); }}>{t("common.edit")}
</Button>
</div>
</td>
+26 -25
View File
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
const SEV_COLOR = {
critical: "text-down",
@@ -19,9 +20,9 @@ const SEV_COLOR = {
type Status = NonNullable<Incident["status"]>;
const NEXT: Partial<Record<Status, { label: string; to: Status }[]>> = {
open: [{ label: "Acknowledge", to: "ack" }, { label: "Resolve", to: "resolved" }],
ack: [{ label: "Resolve", to: "resolved" }],
resolved: [{ label: "Close", to: "closed" }],
open: [{ label: t("incidents.acknowledge"), to: "ack" }, { label: t("incidents.resolve"), to: "resolved" }],
ack: [{ label: t("incidents.resolve"), to: "resolved" }],
resolved: [{ label: t("incidents.close"), to: "closed" }],
};
export function IncidentsPage() {
@@ -83,22 +84,22 @@ export function IncidentsPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Incidents</h1>
<p className="text-[12px] text-text-faint">Auto-opened on monitor outages, or tracked manually.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("incidents.title")}</h1>
<p className="text-[12px] text-text-faint">{t("incidents.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAll((v) => !v)}>
{showAll ? "Open only" : "Show all"}
{showAll ? t("incidents.openOnly") : t("incidents.showAll")}
</Button>
<Button onClick={() => setDlgOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> New incident
<Plus size={13} strokeWidth={2.2} /> {t("incidents.new")}
</Button>
</div>
</div>
{loaded && incidents.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No open incidents.</p>
<p className="text-[13px] text-text-dim">{t("incidents.empty")}</p>
</div>
) : (
<div className="flex flex-col gap-2.5">
@@ -122,9 +123,9 @@ export function IncidentsPage() {
{n.label}
</Button>
))}
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (inc.id && confirm(`Delete incident "${inc.title}"?`)) {
if (inc.id && confirm(t("incidents.deleteConfirm", { title: inc.title ?? "" }))) {
void api.DELETE("/api/incidents/{id}", { params: { path: { id: inc.id } } }).then(load);
}
}}>
@@ -144,14 +145,14 @@ export function IncidentsPage() {
</div>
))}
{(inc.updates ?? []).length === 0 && (
<p className="text-[11.5px] text-text-faint">No updates yet.</p>
<p className="text-[11.5px] text-text-faint">{t("incidents.noUpdates")}</p>
)}
</div>
<div className="flex gap-2">
<Input value={note} onChange={(e) => setNote(e.target.value)}
placeholder="Add a note…" className="h-8 text-[12px]"
placeholder={t("incidents.notePlaceholder")} className="h-8 text-[12px]"
onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} />
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>Post</Button>
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>{t("incidents.post")}</Button>
</div>
</div>
)}
@@ -163,37 +164,37 @@ export function IncidentsPage() {
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New incident</DialogTitle>
<DialogDescription>Track an outage or degradation manually.</DialogDescription>
<DialogTitle>{t("incidents.newTitle")}</DialogTitle>
<DialogDescription>{t("incidents.desc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="inc-title">Title</Label>
<Label htmlFor="inc-title">{t("common.title")}</Label>
<Input id="inc-title" value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="NAS offline" autoFocus />
placeholder={t("incidents.titlePlaceholder")} autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Severity</Label>
<Label>{t("incidents.severity")}</Label>
<Select value={sev} onValueChange={setSev}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="minor">minor</SelectItem>
<SelectItem value="major">major</SelectItem>
<SelectItem value="critical">critical</SelectItem>
<SelectItem value="minor">{t("incidents.sevMinor")}</SelectItem>
<SelectItem value="major">{t("incidents.sevMajor")}</SelectItem>
<SelectItem value="critical">{t("incidents.sevCritical")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="inc-msg">Note (optional)</Label>
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="First observed…" />
<Label htmlFor="inc-msg">{t("incidents.noteOptional")}</Label>
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder={t("incidents.noteFirst")} />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void create()} disabled={busy || !title.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
+72
View File
@@ -0,0 +1,72 @@
import { useState, type FormEvent } from "react";
import { api } from "@/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { t } from "@/i18n";
/** Login/setup gate shown when auth is enabled and no session exists. */
export function LoginPage({ setup, onDone }: { setup: boolean; onDone: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setErr("");
setBusy(true);
const body = { username: username.trim(), password };
const { error, response } = setup
? await api.POST("/api/auth/setup", { body })
: await api.POST("/api/auth/login", { body });
setBusy(false);
if (error) {
setErr(response.status === 429 ? t("auth.rateLimited") : t("auth.invalid"));
return;
}
onDone();
}
return (
<div className="flex min-h-screen items-center justify-center bg-bg p-6">
<form
onSubmit={(e) => void submit(e)}
className="w-full max-w-[320px] rounded-xl border border-border bg-surface p-6 shadow-lg"
>
<div className="mb-5 flex items-center gap-2.5 text-[15px] font-semibold tracking-tight">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden>
<rect x="1" y="1" width="7" height="7" rx="1.5" fill="currentColor" />
<rect x="10" y="1" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
<rect x="1" y="10" width="7" height="7" rx="1.5" fill="currentColor" opacity=".45" />
<rect x="10" y="10" width="7" height="7" rx="1.5" fill="currentColor" />
</svg>
Dash
</div>
<h1 className="mb-1 text-[14px] font-medium">{setup ? t("auth.setupTitle") : t("auth.signInTitle")}</h1>
<p className="mb-4 text-[12px] text-text-faint">{setup ? t("auth.setupHint") : t("auth.signInHint")}</p>
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="auth-user">{t("auth.username")}</Label>
<Input
id="auth-user" autoComplete="username" autoFocus
value={username} onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="auth-pass">{t("auth.password")}</Label>
<Input
id="auth-pass" type="password"
autoComplete={setup ? "new-password" : "current-password"}
value={password} onChange={(e) => setPassword(e.target.value)}
/>
</div>
{err && <p className="text-[12px] text-danger">{err}</p>}
<Button type="submit" disabled={busy || !username.trim() || password.length < 8} className="mt-1">
{setup ? t("auth.createAccount") : t("auth.signIn")}
</Button>
</div>
</form>
</div>
);
}
+60 -15
View File
@@ -7,6 +7,7 @@ import type { Heartbeat, MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
export function MonitorDetailPage({ id }: { id: string }) {
const [m, setM] = useState<MonitorView | null>(null);
@@ -29,7 +30,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
return () => clearInterval(t);
}, [load]);
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
if (!m) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const chart = hbs.map((h) => ({
t: new Date(h.checkedAt ?? "").toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
@@ -40,7 +41,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/monitors" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Monitors
<ArrowLeft size={12} /> {t("nav.monitors")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -51,21 +52,21 @@ export function MonitorDetailPage({ id }: { id: string }) {
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-text-faint">{m.status}</span>
</div>
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · every {m.intervalS}s
{m.type} · {m.url || m.hostname}{m.port ? `:${m.port}` : ""} · {t("monitors.everyInterval", { interval: m.intervalS ?? 0 })}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => void api.POST("/api/monitors/{id}/check", { params: { path: { id } } }).then(load)}>
<RefreshCw size={12} /> Check now
<RefreshCw size={12} /> {t("monitors.checkNow")}
</Button>
<Button variant="outline" size="sm"
onClick={() => void api.PATCH("/api/monitors/{id}", { params: { path: { id } }, body: { active: !m.active } }).then(load)}>
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? "Pause" : "Resume"}
{m.active ? <Pause size={12} /> : <Play size={12} />} {m.active ? t("monitors.pause") : t("monitors.resume")}
</Button>
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>Edit</Button>
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" onClick={() => setDlgOpen(true)}>{t("common.edit")}</Button>
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete monitor "${m.name}"?`)) {
if (confirm(t("monitors.deleteConfirm", { name: m.name ?? "" }))) {
void api.DELETE("/api/monitors/{id}", { params: { path: { id } } }).then(() => { location.href = "/monitors"; });
}
}}>
@@ -76,10 +77,10 @@ export function MonitorDetailPage({ id }: { id: string }) {
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
{[
["Uptime 24h", `${m.stats?.uptime24h?.toFixed(2)}%`],
["Uptime 30d", `${m.stats?.uptime30d?.toFixed(2)}%`],
["Avg ping 24h", m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
["Checks", String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))],
[t("monitors.uptime24h"), `${m.stats?.uptime24h?.toFixed(2)}%`],
[t("monitors.uptime30d"), `${m.stats?.uptime30d?.toFixed(2)}%`],
[t("monitors.avgLatency"), m.stats?.avgPing24h ? `${m.stats.avgPing24h} ms` : "—"],
[t("monitors.checks"), String((m.stats?.up ?? 0) + (m.stats?.down ?? 0))],
].map(([k, v]) => (
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
@@ -88,8 +89,13 @@ export function MonitorDetailPage({ id }: { id: string }) {
))}
</div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.uptime")}</h2>
<div className="mb-6 rounded-[10px] border border-border bg-surface px-3 py-2.5">
<UptimeBar hbs={hbs} hours={hours} />
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Response time</h2>
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.responseTime")}</h2>
<div className="flex gap-1">
{[24, 168, 720].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
@@ -115,7 +121,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
</ResponsiveContainer>
</div>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Recent checks</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("monitors.recentChecks")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
<table className="w-full text-[12px]">
<tbody>
@@ -134,7 +140,7 @@ export function MonitorDetailPage({ id }: { id: string }) {
</tr>
))}
{hbs.length === 0 && (
<tr><td className="px-4 py-6 text-center text-text-faint">No checks in range.</td></tr>
<tr><td className="px-4 py-6 text-center text-text-faint">{t("monitors.noChecks")}</td></tr>
)}
</tbody>
</table>
@@ -144,3 +150,42 @@ export function MonitorDetailPage({ id }: { id: string }) {
</main>
);
}
/** UptimeBar renders the check history as a strip of status-colored bars —
* the window is split into fixed buckets; a bucket is "down" if any check in
* it failed, so outages stay visible at long ranges. */
function UptimeBar({ hbs, hours }: { hbs: Heartbeat[]; hours: number }) {
const BUCKETS = 90;
const now = Date.now();
const span = hours * 3600_000;
const start = now - span;
const buckets: { status: "up" | "down" | "empty"; from: number; to: number; n: number }[] = [];
for (let i = 0; i < BUCKETS; i++) {
buckets.push({ status: "empty", from: start + (i * span) / BUCKETS, to: start + ((i + 1) * span) / BUCKETS, n: 0 });
}
for (const h of hbs) {
const t = new Date(h.checkedAt ?? "").getTime();
if (Number.isNaN(t) || t < start) continue;
const i = Math.min(BUCKETS - 1, Math.floor(((t - start) / span) * BUCKETS));
const b = buckets[i];
if (!b) continue;
b.n++;
if (h.status === "down") b.status = "down";
else if (b.status === "empty") b.status = "up";
}
const fmt = (t: number) => new Date(t).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
return (
<div className="flex h-7 items-stretch gap-[2px]">
{buckets.map((b, i) => (
<div
key={i}
title={`${fmt(b.from)} – ${fmt(b.to)} · ${b.n === 0 ? t("monitors.noChecksBucket") : t("monitors.bucketChecks", { status: b.status, n: b.n })}`}
className={cn(
"flex-1 rounded-[2px]",
b.status === "down" ? "bg-down" : b.status === "up" ? "bg-up/80 hover:bg-up" : "bg-border/50",
)}
/>
))}
</div>
);
}
+18 -17
View File
@@ -6,6 +6,7 @@ import type { MonitorView } from "@/types";
import { MonitorDialog } from "@/components/MonitorDialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
function StatusDot({ status }: { status?: string }) {
return (
@@ -46,19 +47,19 @@ export function MonitorsPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Monitors</h1>
<p className="text-[12px] text-text-faint">Uptime checks on a schedule.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("monitors.title")}</h1>
<p className="text-[12px] text-text-faint">{t("monitors.desc")}</p>
</div>
<Button onClick={() => { setEditing(undefined); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New monitor
<Plus size={13} strokeWidth={2.2} /> {t("monitors.new")}
</Button>
</div>
{loaded && monitors.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No monitors yet.</p>
<p className="text-[13px] text-text-dim">{t("monitors.empty")}</p>
<p className="mt-1 text-[11.5px] text-text-faint">
HTTP, TCP, ping, DNS, keyword, JSON path, or push — checks run server-side on your interval.
{t("monitors.checksHint")}
</p>
</div>
) : (
@@ -66,13 +67,13 @@ export function MonitorsPage() {
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border bg-surface text-left font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium">Name</th>
<th className="px-4 py-2.5 font-medium">Type</th>
<th className="px-4 py-2.5 font-medium">Uptime 24h</th>
<th className="px-4 py-2.5 font-medium">Uptime 30d</th>
<th className="px-4 py-2.5 font-medium">Ping</th>
<th className="px-4 py-2.5 font-medium">Every</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.status")}</th>
<th className="px-4 py-2.5 font-medium">{t("common.name")}</th>
<th className="px-4 py-2.5 font-medium">{t("common.type")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.uptime24h")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.uptime30d")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.ping")}</th>
<th className="px-4 py-2.5 font-medium">{t("monitors.every")}</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
@@ -101,24 +102,24 @@ export function MonitorsPage() {
<td className="px-4 py-3 font-mono text-[11px] text-text-dim">{m.intervalS}s</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="icon" aria-label="Check now"
<Button variant="outline" size="icon" aria-label={t("monitors.checkNow")}
onClick={() => void act(m.id, () => api.POST("/api/monitors/{id}/check", { params: { path: { id: m.id! } } }))}>
<RefreshCw size={12} />
</Button>
<Button variant="outline" size="icon" aria-label={m.active ? "Pause" : "Resume"}
<Button variant="outline" size="icon" aria-label={m.active ? t("monitors.pause") : t("monitors.resume")}
onClick={() => void act(m.id, () => api.PATCH("/api/monitors/{id}", { params: { path: { id: m.id! } }, body: { active: !m.active } }))}>
{m.active ? <Pause size={12} /> : <Play size={12} />}
</Button>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete monitor "${m.name}"?`)) {
if (confirm(t("monitors.deleteConfirm", { name: m.name ?? "" }))) {
void act(m.id, () => api.DELETE("/api/monitors/{id}", { params: { path: { id: m.id! } } }));
}
}}>
<Trash2 size={12} />
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditing(m); setDlgOpen(true); }}>
Edit
{t("common.edit")}
</Button>
</div>
</td>
+38 -37
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Admin for public status pages + maintenance windows. The public view
// itself lives at /status/:slug.
@@ -63,7 +64,7 @@ export function StatusPagesPage() {
},
});
setBusy(false);
if (error) { setErr("Could not create — slug may be taken."); return; }
if (error) { setErr(t("statusPages.createError")); return; }
setPageOpen(false); setPTitle(""); setPSlug(""); setPDesc(""); setPMonitors([]);
void load();
}
@@ -79,7 +80,7 @@ export function StatusPagesPage() {
},
});
setBusy(false);
if (error) { setErr("Invalid window — check times."); return; }
if (error) { setErr(t("maintenance.createError")); return; }
setWinOpen(false); setWTitle(""); setWStart(""); setWEnd("");
void load();
}
@@ -88,17 +89,17 @@ export function StatusPagesPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Status pages</h1>
<p className="text-[12px] text-text-faint">Public live views at <code className="font-mono">/status/&lt;slug&gt;</code>.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("statusPages.title")}</h1>
<p className="text-[12px] text-text-faint">{t("statusPages.publicAt")} <code className="font-mono">/status/&lt;slug&gt;</code>.</p>
</div>
<Button onClick={() => setPageOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> New page
<Plus size={13} strokeWidth={2.2} /> {t("statusPages.new")}
</Button>
</div>
{loaded && pages.length === 0 ? (
<div className="mb-8 rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
<p className="text-[13px] text-text-dim">No status pages yet.</p>
<p className="text-[13px] text-text-dim">{t("statusPages.empty")}</p>
</div>
) : (
<div className="mb-8 flex flex-col gap-2">
@@ -107,11 +108,11 @@ export function StatusPagesPage() {
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{p.title}</span>
<code className="font-mono text-[11px] text-text-faint">/status/{p.slug}</code>
<Link href={`/status/${p.slug}`} className="shrink-0">
<Button variant="outline" size="icon" aria-label="Open public page"><ExternalLink size={12} /></Button>
<Button variant="outline" size="icon" aria-label={t("statusPages.open")}><ExternalLink size={12} /></Button>
</Link>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (p.id && confirm(`Delete status page "${p.title}"?`)) {
if (p.id && confirm(t("statusPages.deleteConfirm", { title: p.title ?? "" }))) {
void api.DELETE("/api/status-pages/{id}", { params: { path: { id: p.id } } }).then(load);
}
}}>
@@ -124,17 +125,17 @@ export function StatusPagesPage() {
<div className="mb-5 flex items-center justify-between">
<div>
<h2 className="text-[15px] font-semibold tracking-tight">Maintenance windows</h2>
<p className="text-[12px] text-text-faint">Active windows mute alerts + auto-incidents and mark covered monitors.</p>
<h2 className="text-[15px] font-semibold tracking-tight">{t("maintenance.title")}</h2>
<p className="text-[12px] text-text-faint">{t("maintenance.subtitle")}</p>
</div>
<Button variant="outline" onClick={() => setWinOpen(true)}>
<Plus size={13} strokeWidth={2.2} /> Schedule
<Plus size={13} strokeWidth={2.2} /> {t("maintenance.schedule")}
</Button>
</div>
{loaded && windows.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
<p className="text-[13px] text-text-dim">No maintenance scheduled.</p>
<p className="text-[13px] text-text-dim">{t("maintenance.empty")}</p>
</div>
) : (
<div className="flex flex-col gap-2">
@@ -146,11 +147,11 @@ export function StatusPagesPage() {
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
</span>
<span className={cn("font-mono text-[10px] uppercase", w.active ? "text-up" : "text-text-faint")}>
{w.active ? "active" : "scheduled"}
{w.active ? t("maintenance.active") : t("maintenance.scheduled")}
</span>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => {
if (w.id && confirm(`Delete window "${w.title}"?`)) {
if (w.id && confirm(t("maintenance.deleteConfirm", { title: w.title ?? "" }))) {
void api.DELETE("/api/maintenance/{id}", { params: { path: { id: w.id } } }).then(load);
}
}}>
@@ -164,26 +165,26 @@ export function StatusPagesPage() {
<Dialog open={pageOpen} onOpenChange={setPageOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New status page</DialogTitle>
<DialogDescription>Public live view over selected monitors.</DialogDescription>
<DialogTitle>{t("statusPages.newTitle")}</DialogTitle>
<DialogDescription>{t("statusPages.newDesc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-title">Title</Label>
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder="Homelab status" autoFocus />
<Label htmlFor="sp-title">{t("common.title")}</Label>
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder={t("statusPages.titlePlaceholder")} autoFocus />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-slug">Slug</Label>
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" />
<Label htmlFor="sp-slug">{t("common.slug")}</Label>
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder={t("statusPages.slugPlaceholder")} className="font-mono text-[12px]" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="sp-desc">Description</Label>
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder="optional" />
<Label htmlFor="sp-desc">{t("common.description")}</Label>
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder={t("common.optional")} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Monitors</Label>
<Label>{t("statusPages.monitors")}</Label>
<div className="max-h-40 overflow-y-auto rounded-[7px] border border-border p-2">
{monitors.map((m) => (
<label key={m.id} className="flex items-center gap-2 px-1 py-1 text-[12px]">
@@ -194,16 +195,16 @@ export function StatusPagesPage() {
{m.name}
</label>
))}
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">No monitors yet — empty selection shows all.</p>}
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">{t("statusPages.noMonitors")}</p>}
</div>
<p className="text-[11px] text-text-faint">Leave all unchecked to include every active monitor.</p>
<p className="text-[11px] text-text-faint">{t("statusPages.allHint")}</p>
</div>
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void createPage()} disabled={busy || !pTitle.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
@@ -212,30 +213,30 @@ export function StatusPagesPage() {
<Dialog open={winOpen} onOpenChange={setWinOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Schedule maintenance</DialogTitle>
<DialogDescription>Suppresses alerts and auto-incidents for the window.</DialogDescription>
<DialogTitle>{t("maintenance.dialogTitle")}</DialogTitle>
<DialogDescription>{t("maintenance.dialogDesc")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-title">Title</Label>
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus />
<Label htmlFor="mw-title">{t("common.title")}</Label>
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder={t("maintenance.titlePlaceholder")} autoFocus />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-start">Starts</Label>
<Label htmlFor="mw-start">{t("maintenance.starts")}</Label>
<Input id="mw-start" type="datetime-local" value={wStart} onChange={(e) => setWStart(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="mw-end">Ends</Label>
<Label htmlFor="mw-end">{t("maintenance.ends")}</Label>
<Input id="mw-end" type="datetime-local" value={wEnd} onChange={(e) => setWEnd(e.target.value)} />
</div>
</div>
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void createWindow()} disabled={busy || !wTitle.trim() || !wStart || !wEnd}>
{busy ? "Saving…" : "Schedule"}
{busy ? t("common.saving") : t("maintenance.schedule")}
</Button>
</DialogFooter>
</DialogContent>
+12 -11
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "@/api";
import type { PublicStatus } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Public status page — clean read-only view; refreshes every 30s.
// Route: /status/:slug -> GET /api/status-pages/:slug/public.
@@ -9,10 +10,10 @@ import { cn } from "@/lib/utils";
// status strings arrive as plain `string` — map with functions, not
// open dictionaries, so the lookup type stays honest.
function overallBanner(overall?: string) {
if (overall === "down") return { label: "Major outage", cls: "bg-down" };
if (overall === "maintenance") return { label: "Under maintenance", cls: "bg-text-faint/60" };
if (overall === "up") return { label: "All systems operational", cls: "bg-up" };
return { label: "Checking…", cls: "bg-text-faint/40" };
if (overall === "down") return { label: t("statusPublic.outage"), cls: "bg-down" };
if (overall === "maintenance") return { label: t("statusPublic.underMaintenance"), cls: "bg-text-faint/60" };
if (overall === "up") return { label: t("statusPublic.operational"), cls: "bg-up" };
return { label: t("statusPublic.checking"), cls: "bg-text-faint/40" };
}
function dotCls(status?: string) {
@@ -38,8 +39,8 @@ export function StatusPublicPage({ slug }: { slug: string }) {
return () => clearInterval(t);
}, [load]);
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Status page not found.</main>;
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Loading…</main>;
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">{t("statusPublic.notFound")}</main>;
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">{t("common.loading")}</main>;
const banner = overallBanner(st.overall);
@@ -56,7 +57,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(st.monitors ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Services</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.services")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.monitors ?? []).map((m) => (
<div key={m.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
@@ -72,7 +73,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(st.systems ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Systems</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.systems")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.systems ?? []).map((s) => (
<div key={s.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
@@ -87,7 +88,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(st.maintenance ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Maintenance</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.maintenance")}</h2>
<div className="overflow-hidden rounded-[10px] border border-border">
{(st.maintenance ?? []).map((w) => (
<div key={w.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
@@ -103,7 +104,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
{(st.incidents ?? []).length > 0 && (
<section className="mb-6">
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Incidents</h2>
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("statusPublic.incidents")}</h2>
<div className="flex flex-col gap-2">
{(st.incidents ?? []).map((inc) => (
<div key={inc.id} className="rounded-[10px] border border-border px-4 py-3">
@@ -122,7 +123,7 @@ export function StatusPublicPage({ slug }: { slug: string }) {
</section>
)}
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">powered by dash</p>
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">{t("statusPublic.footer")}</p>
</main>
);
}
+109 -21
View File
@@ -7,6 +7,7 @@ import type { System, SystemStat } from "@/types";
import { agentCmd, fmtBytes, fmtUptime } from "@/pages/SystemsPage";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
export function SystemDetailPage({ id }: { id: string }) {
const [sys, setSys] = useState<System | null>(null);
@@ -32,11 +33,11 @@ export function SystemDetailPage({ id }: { id: string }) {
if (notFound) return (
<main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> Systems</Link>
<p className="mt-4">System not found.</p>
<Link href="/systems" className="inline-flex items-center gap-1.5 hover:text-text"><ArrowLeft size={12} /> {t("nav.systems")}</Link>
<p className="mt-4">{t("systems.notFound")}</p>
</main>
);
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">Loading…</main>;
if (!sys) return <main className="mx-auto max-w-5xl px-7 py-8 text-[13px] text-text-faint">{t("common.loading")}</main>;
const l = sys.latest;
const chart = stats.map((s) => ({
@@ -53,7 +54,7 @@ export function SystemDetailPage({ id }: { id: string }) {
return (
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<Link href="/systems" className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-text-faint hover:text-text">
<ArrowLeft size={12} /> Systems
<ArrowLeft size={12} /> {t("nav.systems")}
</Link>
<div className="mb-6 flex items-start justify-between">
@@ -66,14 +67,14 @@ export function SystemDetailPage({ id }: { id: string }) {
</div>
<p className="mt-1 font-mono text-[11.5px] text-text-faint">
{sys.host ? `${sys.host} · ` : ""}{sys.os}/{sys.arch}
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${sys.cores} cores` : ""}
{sys.lastSeenAt ? ` · seen ${new Date(sys.lastSeenAt).toLocaleTimeString()}` : " · never seen"}
{sys.cpuModel ? ` · ${sys.cpuModel}` : ""}{sys.cores ? ` · ${t("systems.cores", { n: sys.cores })}` : ""}
{sys.lastSeenAt ? ` · ${t("systems.seenAt", { time: new Date(sys.lastSeenAt).toLocaleTimeString() })}` : ` · ${t("systems.neverSeen")}`}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" aria-label="Delete"
<Button variant="outline" size="sm" aria-label={t("common.delete")}
onClick={() => {
if (confirm(`Delete system "${sys.name}"?`)) {
if (confirm(t("systems.deleteConfirm", { name: sys.name ?? "" }))) {
void api.DELETE("/api/systems/{id}", { params: { path: { id } } }).then(() => { location.href = "/systems"; });
}
}}>
@@ -84,10 +85,10 @@ export function SystemDetailPage({ id }: { id: string }) {
<div className="mb-6 grid grid-cols-4 gap-3 max-[720px]:grid-cols-2">
{[
["CPU", l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
["Memory", l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
["Disk /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
["Uptime", l?.uptimeS ? fmtUptime(l.uptimeS) : "—"],
[t("systems.cpu"), l?.cpu !== undefined ? `${l.cpu.toFixed(1)}%` : "—"],
[t("systems.mem"), l?.memTotal ? `${fmtBytes(l.memUsed ?? 0)} / ${fmtBytes(l.memTotal)}` : "—"],
[t("systems.disk") + " /", l?.diskTotal ? `${fmtBytes(l.diskUsed ?? 0)} / ${fmtBytes(l.diskTotal)}` : "—"],
[t("systems.uptime"), l?.uptimeS ? fmtUptime(l.uptimeS) : "—"],
].map(([k, v]) => (
<div key={k} className="rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{k}</div>
@@ -97,7 +98,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</div>
<div className="mb-2 flex items-center justify-between">
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Usage</h2>
<h2 className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">{t("systems.usage")}</h2>
<div className="flex gap-1">
{[1, 6, 24, 168].map((h) => (
<button key={h} type="button" onClick={() => setHours(h)}
@@ -111,7 +112,7 @@ export function SystemDetailPage({ id }: { id: string }) {
<div className="mb-6 grid grid-cols-2 gap-3 max-[720px]:grid-cols-1">
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">CPU %</div>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.cpuPct")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
@@ -123,7 +124,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Memory %</div>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.memPct")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
@@ -135,7 +136,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Network KB/s</div>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.netKbs")}</div>
<ResponsiveContainer width="100%" height="85%">
<LineChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
@@ -148,7 +149,7 @@ export function SystemDetailPage({ id }: { id: string }) {
</ResponsiveContainer>
</div>
<div className="h-44 rounded-[10px] border border-border bg-surface p-3">
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">Load 1m</div>
<div className="mb-1 font-mono text-[9.5px] uppercase tracking-[0.1em] text-text-faint">{t("systems.load1m")}</div>
<ResponsiveContainer width="100%" height="85%">
<AreaChart data={chart} margin={{ top: 4, right: 4, bottom: 0, left: -22 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
@@ -165,7 +166,7 @@ export function SystemDetailPage({ id }: { id: string }) {
{l?.temps && Object.keys(l.temps).length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
Temperatures
{t("systems.temperatures")}
</h2>
<table className="w-full text-[12px]">
<tbody>
@@ -182,13 +183,23 @@ export function SystemDetailPage({ id }: { id: string }) {
{l?.containers && l.containers.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
Containers
{t("systems.containers")}
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.containers.map((ctr) => (
<tr key={ctr.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{ctr.name}</td>
<td className="px-4 py-2 text-right font-mono text-[11px] text-text-faint">
{ctr.state === "running" && (ctr.cpu || ctr.memUsed) ? (
<>
{(ctr.cpu ?? 0).toFixed(1)}%
{" · "}
{fmtBytes(ctr.memUsed ?? 0)}
{ctr.memLimit ? ` / ${fmtBytes(ctr.memLimit)}` : ""}
</>
) : null}
</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
ctr.state === "running" ? "text-up" : "text-text-faint")}>
@@ -201,15 +212,92 @@ export function SystemDetailPage({ id }: { id: string }) {
</table>
</div>
)}
{l?.smart && l.smart.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
{t("systems.drivesSmart")}
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.smart.map((d) => (
<tr key={d.device} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{d.device}</td>
<td className="px-4 py-2 text-[11px] text-text-dim">{d.model}</td>
<td className="px-4 py-2 text-right font-mono text-[11px]">
{d.tempC ? `${d.tempC.toFixed(0)}°C` : ""}
</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
d.passed === false ? "text-destructive" : "text-up")}>
{d.passed === false ? t("systems.failing") : d.passed ? "ok" : "—"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{l?.zfs && l.zfs.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
{t("systems.zfs")}
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.zfs.map((p) => (
<tr key={p.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{p.name}</td>
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">
{p.size ? `${fmtBytes((p.size ?? 0) - (p.free ?? 0))} / ${fmtBytes(p.size)}` : ""}
</td>
<td className="px-4 py-2 text-right">
<span className={cn("font-mono text-[10.5px] uppercase",
p.health === "ONLINE" ? "text-up" : "text-destructive")}>
{p.health}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{l?.gpu && l.gpu.length > 0 && (
<div className="rounded-[10px] border border-border">
<h2 className="border-b border-border bg-surface px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">
GPU
</h2>
<table className="w-full text-[12px]">
<tbody>
{l.gpu.map((g) => (
<tr key={g.name} className="border-b border-border/60 last:border-0">
<td className="px-4 py-2 font-mono text-[11px] text-text-dim">{g.name}</td>
<td className="px-4 py-2 text-right font-mono text-[11px] text-text-dim">
{[
g.utilPct ? `${g.utilPct.toFixed(0)}%` : "",
g.tempC ? `${g.tempC.toFixed(0)}°C` : "",
g.memTotal ? `${fmtBytes(g.memUsed ?? 0)} / ${fmtBytes(g.memTotal)}` : "",
].filter(Boolean).join(" · ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-6 rounded-[10px] border border-border bg-surface px-4 py-3">
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">Agent command</div>
<div className="mb-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-faint">{t("systems.agentCmd")}</div>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap font-mono text-[11px] text-text-dim">
{agentCmd(sys)}
</code>
<Button variant="outline" size="icon" aria-label="Copy"
<Button variant="outline" size="icon" aria-label={t("systems.copy")}
onClick={() => void navigator.clipboard.writeText(agentCmd(sys))}>
<Copy size={12} />
</Button>
+20 -19
View File
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
const TB = 2 ** 40;
const GB = 2 ** 30;
@@ -79,7 +80,7 @@ export function SystemsPage() {
}
async function remove(id: string | undefined, n: string | undefined) {
if (!id || !confirm(`Delete system "${n}"?`)) return;
if (!id || !confirm(t("systems.deleteConfirm", { name: n ?? "" }))) return;
await api.DELETE("/api/systems/{id}", { params: { path: { id } } });
await load();
}
@@ -88,19 +89,19 @@ export function SystemsPage() {
<main className="mx-auto w-full max-w-5xl px-7 py-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-[15px] font-semibold tracking-tight">Systems</h1>
<p className="text-[12px] text-text-faint">Servers and PCs reporting via dash-agent.</p>
<h1 className="text-[15px] font-semibold tracking-tight">{t("systems.title")}</h1>
<p className="text-[12px] text-text-faint">{t("systems.subtitle")}</p>
</div>
<Button onClick={() => { setCreated(null); setDlgOpen(true); }}>
<Plus size={13} strokeWidth={2.2} /> New system
<Plus size={13} strokeWidth={2.2} /> {t("systems.new")}
</Button>
</div>
{loaded && systems.length === 0 ? (
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
<p className="text-[13px] text-text-dim">No systems yet.</p>
<p className="text-[13px] text-text-dim">{t("systems.empty")}</p>
<p className="mt-1 text-[11.5px] text-text-faint">
Add one, then run <code className="font-mono">dash-agent</code> on the host with the issued token.
{t("systems.emptyHintPre")} <code className="font-mono">dash-agent</code> {t("systems.emptyHintPost")}
</p>
</div>
) : (
@@ -122,18 +123,18 @@ export function SystemsPage() {
<Link href={`/systems/${sys.id}`} className="min-w-0 flex-1 truncate text-[13px] font-medium hover:underline">
{sys.name}
</Link>
<Button variant="outline" size="icon" aria-label="Delete"
<Button variant="outline" size="icon" aria-label={t("common.delete")}
onClick={() => void remove(sys.id, sys.name)}>
<Trash2 size={11} />
</Button>
</div>
{sys.host ? (
<p className="mb-2.5 truncate font-mono text-[10.5px] text-text-faint">
{sys.host} · {sys.cpuModel || `${sys.cores} cores`}
{sys.host} · {sys.cpuModel || t("systems.cores", { n: sys.cores ?? 0 })}
{l?.uptimeS ? ` · up ${fmtUptime(l.uptimeS)}` : ""}
</p>
) : (
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">waiting for agent…</p>
<p className="mb-2.5 font-mono text-[10.5px] text-text-faint">{t("systems.waiting")}</p>
)}
<div className="flex flex-col gap-1.5">
{rows.map(([label, pct, txt]) => (
@@ -153,43 +154,43 @@ export function SystemsPage() {
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New system</DialogTitle>
<DialogDescription>Name the host, then run dash-agent on it with the issued token.</DialogDescription>
<DialogTitle>{t("systems.newTitle")}</DialogTitle>
<DialogDescription>{t("systems.newDesc")}</DialogDescription>
</DialogHeader>
{created ? (
<div className="flex flex-col gap-3">
<p className="text-[12px] text-text-dim">
Agent command for <span className="font-medium text-text">{created.name}</span>:
{t("systems.agentCmdFor")} <span className="font-medium text-text">{created.name}</span>:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded-[7px] border border-border bg-bg px-3 py-2 font-mono text-[11px]">
{agentCmd(created)}
</code>
<Button variant="outline" size="icon" aria-label="Copy"
<Button variant="outline" size="icon" aria-label={t("systems.copy")}
onClick={() => void navigator.clipboard.writeText(agentCmd(created))}>
<Copy size={12} />
</Button>
</div>
<p className="text-[11px] text-text-faint">
The token identifies this system — keep it out of public repos.
{t("systems.tokenWarning")}
</p>
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label htmlFor="sys-name">Name</Label>
<Label htmlFor="sys-name">{t("common.name")}</Label>
<Input id="sys-name" value={name} onChange={(e) => setName(e.target.value)}
placeholder="nas" autoFocus
placeholder={t("systems.namePlaceholder")} autoFocus
onKeyDown={(e) => { if (e.key === "Enter") void create(); }} />
</div>
)}
<DialogFooter>
{created ? (
<Button onClick={() => setDlgOpen(false)}>Done</Button>
<Button onClick={() => setDlgOpen(false)}>{t("systems.done")}</Button>
) : (
<>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>{t("common.cancel")}</Button>
<Button onClick={() => void create()} disabled={busy || !name.trim()}>
{busy ? "Creating…" : "Create"}
{busy ? t("common.creating") : t("common.create")}
</Button>
</>
)}
+3 -2
View File
@@ -1,5 +1,6 @@
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
import { WidgetContent } from "@/widgets";
@@ -28,7 +29,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
<div className="group/sec flex cursor-pointer select-none items-center gap-3 px-0.5 py-1">
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -54,7 +55,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
+3 -2
View File
@@ -1,5 +1,6 @@
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { IconImg, ItemAnchor, StatusChip, hostOf } from "@/board/primitives";
import { WidgetContent } from "@/widgets";
@@ -32,7 +33,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -41,7 +42,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { ChevronDown, GripVertical, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { useBoard } from "@/board/store";
import { IconImg, ItemAnchor, hostOf } from "@/board/primitives";
@@ -37,10 +38,10 @@ function BoardView({ children }: BoardViewProps) {
}).format(now);
const cells: [string, string, string][] = [
["Local time", time, date.split(",")[0] ?? ""],
[t("renderer.localTime"), time, date.split(",")[0] ?? ""],
["Services", String(services.length), `${board.sections.length} sections`],
["Up", String(up), "responding"],
["Down", String(down), down === 0 ? "all green" : "offline"],
[t("renderer.down"), String(down), down === 0 ? t("renderer.allGreen") : t("renderer.offline")],
["Widgets", String(widgets.length), "registered"],
];
@@ -77,7 +78,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
<div className="group/sec mb-0.5 flex cursor-pointer select-none items-baseline gap-3.5 border-b border-border-strong pb-2.5">
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab self-center text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -96,7 +97,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
{section.name}
</h2>
<span className="font-mono text-[11px] text-text-faint">
{section.items.length} {section.items.length === 1 ? "entry" : "entries"}
{section.items.length} {t(section.items.length === 1 ? "renderer.entry" : "renderer.entries")}
</span>
<ChevronDown
size={14}
@@ -105,7 +106,7 @@ function SectionView({ section, index, onToggle, onDelete, children }: SectionVi
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="self-center text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { ChevronDown, Pencil, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
import { useItemDnd, useSectionDnd } from "@/board/dnd";
import { useBoard } from "@/board/store";
import { IconImg, ItemAnchor, hostOf } from "@/board/primitives";
@@ -42,7 +43,7 @@ function BoardView({ children }: BoardViewProps) {
const panels: { head: string; v: string; s: string; live?: boolean; wide?: boolean }[] = [
{ head: "clock --local", v: time, s: date, live: true, wide: true },
{ head: "services --up", v: String(up), s: `of ${probed} probed` },
{ head: "services --down", v: String(down), s: down === 0 ? "all green" : "needs attention" },
{ head: "services --down", v: String(down), s: down === 0 ? t("renderer.allGreen") : t("renderer.needsAttention") },
{ head: "widgets", v: String(widgets.length), s: "registered" },
{ head: "endpoints", v: String(endpoints), s: "urls indexed" },
{ head: "uptime --probes", v: probed > 0 ? `${Math.round((up / probed) * 100)}%` : "—", s: `${up}/${probed} responding` },
@@ -56,7 +57,7 @@ function BoardView({ children }: BoardViewProps) {
<span className="font-medium text-text-dim">{services.length} services</span>
{` indexed · `}
<span className="text-up">{up} up</span>
{down > 0 ? <span>{` · `}<span className="text-down">{down} down</span></span> : ` · all green`}
{down > 0 ? <span>{` · `}<span className="text-down">{t("renderer.downCount", { n: down })}</span></span> : ` · ${t("renderer.allGreen")}`}
{` · `}
<span className="font-medium text-text-dim">{widgets.length} widgets</span>
{`\ndrag rows to reorder · click rows with multiple endpoints to choose`}
@@ -70,7 +71,7 @@ function BoardView({ children }: BoardViewProps) {
>
<div className="flex items-center gap-1.5 border-b border-border px-3 py-2 text-[10px] uppercase tracking-[0.1em] text-text-faint">
{p.head}
{p.live ? <span className="ml-auto text-[9px] text-up">● live</span> : null}
{p.live ? <span className="ml-auto text-[9px] text-up">● {t("renderer.live")}</span> : null}
</div>
<div className="px-3 py-3">
<div className={cn("font-medium leading-none tracking-tight tabular-nums", p.wide ? "text-[26px]" : "text-[22px]")}>{p.v}</div>
@@ -108,7 +109,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Drag section"
aria-label={t("renderer.dragSection")}
className="cursor-grab text-text-faint opacity-0 transition-opacity group-hover/sec:opacity-100 active:cursor-grabbing"
{...dnd.attributes}
{...dnd.listeners}
@@ -117,7 +118,7 @@ function SectionView({ section, onToggle, onDelete, children }: SectionViewProps
</button>
<button
type="button"
aria-label="Delete section"
aria-label={t("renderer.deleteSection")}
onClick={onDelete}
className="text-text-faint opacity-0 transition-opacity hover:text-destructive group-hover/sec:opacity-100"
>
+15
View File
@@ -2,6 +2,7 @@ import type { components, paths } from "@dash/api-client";
// Board entities — generated from openapi.yaml. Never hand-edit shapes here.
export type Section = components["schemas"]["Section"];
export type Board = components["schemas"]["Board"];
export type Item = components["schemas"]["Item"];
export type BoardUrl = components["schemas"]["Url"];
export type UrlInput = components["schemas"]["UrlInput"];
@@ -20,6 +21,7 @@ export type Domain = components["schemas"]["Domain"];
export type DomainInput = components["schemas"]["DomainInput"];
export type DomainView = components["schemas"]["DomainView"];
export type DomainCheck = components["schemas"]["DomainCheck"];
export type Subdomain = components["schemas"]["Subdomain"];
export type System = components["schemas"]["System"];
export type StatSample = components["schemas"]["StatSample"];
/** Stats rows are StatSample plus the server-side timestamp. */
@@ -41,13 +43,26 @@ export type SettingsWire = NonNullable<
export interface Prefs {
theme: Theme;
renderer: RendererName;
/** CSS color override for --accent; empty = theme default. */
accent: string;
/** Background image URL for the board; empty = none. */
wallpaper: string;
/** Raw user CSS injected into a <style> tag; empty = none. */
customCss: string;
/** UI language — matches a key in i18n LOCALES; empty = en. */
locale: string;
}
/** Narrow the settings map to typed prefs; unknown/absent keys fall back. */
export function parsePrefs(raw: SettingsWire): Prefs {
const r = raw.renderer;
const s = (v: unknown) => (typeof v === "string" ? v : "");
return {
theme: raw.theme === "light" ? "light" : "dark",
renderer: r === "cards" || r === "index" || r === "console" ? r : "bento",
accent: s(raw.accent),
wallpaper: s(raw.wallpaper),
customCss: s(raw.custom_css),
locale: s(raw.locale),
};
}
+5 -4
View File
@@ -1,5 +1,6 @@
import type { Item } from "@/types";
import type { WidgetData } from "@/widgets/index";
import { t } from "@/i18n";
// Shared DNS-blocker layout: big block-rate, queries + blocked counts.
// Pi-hole and AdGuard return the same fields from their fetchers.
@@ -23,12 +24,12 @@ function BlockerWidget({ item, data, error }: { item: Item; data: WidgetData | u
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{data ? `${pct.toFixed(1)}%` : "…"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">blocked</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">{t("widget.blockedLbl")}</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
<span>{fmt(num(data, "queriesToday"))} queries</span>
<span>{fmt(num(data, "blockedToday"))} blocked</span>
{typeof data?.avgProcessMs === "number" && <span>{num(data, "avgProcessMs").toFixed(0)} ms avg</span>}
<span>{t("widget.queries", { n: fmt(num(data, "queriesToday")) })}</span>
<span>{t("widget.blocked", { n: fmt(num(data, "blockedToday")) })}</span>
{typeof data?.avgProcessMs === "number" && <span>{t("widget.msAvg", { n: num(data, "avgProcessMs").toFixed(0) })}</span>}
</div>
</div>
);
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for a tracked domain: favicon, name, expiry countdown.
export function DomainWidget({ item, data, error }: {
@@ -29,7 +30,7 @@ export function DomainWidget({ item, data, error }: {
"shrink-0 font-mono text-[10.5px]",
error ? "text-down" : expiring || (days !== undefined && days < 0) ? "text-down" : "text-text-faint",
)}>
{error ? "err" : days === undefined ? "…" : days < 0 ? "expired" : `${days}d`}
{error ? t("widget.err") : days === undefined ? "…" : days < 0 ? t("domains.expired") : `${days}d`}
{sslDays !== undefined && sslDays <= 30 ? ` · ssl ${sslDays}d` : ""}
</span>
</a>
+23
View File
@@ -0,0 +1,23 @@
import type { Item } from "@/types";
import { cfgStr } from "@/widgets/index";
import { t } from "@/i18n";
// Embed — sandboxed iframe. Fully client-side; the tile chrome still shows
// the item name above the frame.
export function EmbedWidget({ item }: { item: Item }) {
const url = cfgStr(item, "url");
// Block script-capable schemes; http(s)/relative URLs are fine.
const bad = /^\s*(javascript|data|vbscript|file):/i.test(url);
if (!url || bad) {
return <span className="text-[11px] text-text-faint">{t("widget.embedHint")}</span>;
}
return (
<iframe
src={url}
title={item.name || "embed"}
sandbox="allow-scripts allow-same-origin allow-forms"
referrerPolicy="no-referrer"
className="pointer-events-auto h-full min-h-[120px] w-full rounded-[6px] border border-border bg-bg"
/>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import type { Item } from "@/types";
import type { WidgetData } from "@/widgets/index";
import { t } from "@/i18n";
// Immich — photo/video counts and library footprint.
@@ -24,10 +25,10 @@ export function ImmichWidget({ item, data, error }: { item: Item; data: WidgetDa
<span className="font-mono text-[22px] font-medium leading-none tracking-tight tabular-nums">
{data ? fmtNum(num(data, "photos")) : "…"}
</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">photos</span>
<span className="text-[10.5px] uppercase tracking-[0.1em] text-text-faint">{t("widget.photosLbl")}</span>
</div>
<div className="ml-auto flex flex-col items-end gap-1 font-mono text-[10.5px] text-text-faint">
<span>{fmtNum(num(data, "videos"))} videos</span>
<span>{t("widget.videos", { n: fmtNum(num(data, "videos")) })}</span>
<span>{fmtBytes(num(data, "usageBytes"))}</span>
</div>
</div>
+7 -2
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from "react";
import type { Item } from "@/types";
import { t } from "@/i18n";
import { ClockWidget } from "@/widgets/clock";
import { PiholeWidget, AdguardWidget } from "@/widgets/blocker";
import { ImmichWidget } from "@/widgets/immich";
import { MonitorWidget } from "@/widgets/monitor";
import { DomainWidget } from "@/widgets/domain";
import { SystemWidget } from "@/widgets/system";
import { EmbedWidget } from "@/widgets/embed";
import { JsonPathWidget } from "@/widgets/jsonpath";
// WidgetHost seam: renderers own the tile chrome; this maps config.type to
// the component that fills it. Adding a widget = one fetcher in Go + one
@@ -34,6 +37,8 @@ const registry: WidgetRegistry = {
monitor: { view: MonitorWidget },
domain: { view: DomainWidget },
system: { view: SystemWidget },
embed: { local: true, view: EmbedWidget },
jsonpath: { view: JsonPathWidget },
};
export function cfgStr(item: Item, key: string): string {
@@ -66,7 +71,7 @@ export function useWidgetData(item: Item): { data: WidgetData | undefined; error
const body: unknown = await res.json();
if (dead) return;
if (!res.ok) {
let msg = "fetch failed";
let msg = t("widget.fetchFailed");
if (typeof body === "object" && body !== null && "error" in body) {
msg = String(body.error);
}
@@ -79,7 +84,7 @@ export function useWidgetData(item: Item): { data: WidgetData | undefined; error
setState({ data, error: undefined });
}
} catch {
if (!dead) setState((s) => ({ data: s.data, error: "offline" }));
if (!dead) setState((s) => ({ data: s.data, error: t("widget.offline") }));
}
if (!dead) timer = setTimeout(tick, 30_000);
};
+22
View File
@@ -0,0 +1,22 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
// JSON value — renders the scalar the backend extracted at config.path.
export function JsonPathWidget({ item, data, error }: {
item: Item;
data: WidgetData | undefined;
error: string | undefined;
}) {
const label = typeof data?.label === "string" && data.label ? data.label : item.name;
const value = data?.value;
return (
<div className="flex min-w-0 flex-col justify-center gap-0.5">
<span className="truncate text-[10.5px] uppercase tracking-[0.1em] text-text-faint">
{label}
</span>
<span className="truncate font-mono text-[18px] font-medium leading-tight">
{error ? "err" : value === undefined ? "…" : typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for an uptime monitor: status dot, uptime %, avg ping.
// config.monitorId binds it; data comes from the monitor fetcher.
@@ -30,7 +31,7 @@ export function MonitorWidget({ item, data, error }: {
{typeof data?.name === "string" ? data.name : item.name}
</span>
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">
{error ? "err" : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
{error ? t("widget.err") : uptime !== undefined ? `${uptime.toFixed(1)}%` : "…"}
{ping ? ` · ${Math.round(ping)}ms` : ""}
</span>
</a>
+2 -1
View File
@@ -1,6 +1,7 @@
import type { WidgetData } from "@/widgets";
import type { Item } from "@/types";
import { cn } from "@/lib/utils";
import { t } from "@/i18n";
// Board tile for a monitored system: status dot + cpu/mem/disk mini bars.
// config.systemId binds it; data comes from the system fetcher.
@@ -47,7 +48,7 @@ export function SystemWidget({ item, data, error }: {
{typeof data?.name === "string" ? data.name : item.name}
</span>
{error ? (
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">err</span>
<span className="shrink-0 font-mono text-[10.5px] text-text-faint">{t("widget.err")}</span>
) : (
<span className="flex shrink-0 items-center gap-2">
{bar(cpu, "cpu")}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+325 -5
View File
@@ -27,10 +27,88 @@ paths:
schema:
$ref: "#/components/schemas/Health"
/api/boards:
get:
operationId: listBoards
summary: All boards, ordered
responses:
"200":
description: Board list
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Board"
post:
operationId: createBoard
summary: Create a board — slug derived from the name
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
responses:
"201":
description: Created board
content:
application/json:
schema:
$ref: "#/components/schemas/Board"
/api/boards/{id}:
patch:
operationId: updateBoard
summary: Rename a board — the slug stays stable
parameters:
- $ref: "#/components/parameters/id"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
responses:
"200":
description: Updated board
content:
application/json:
schema:
$ref: "#/components/schemas/Board"
"404":
$ref: "#/components/responses/NotFound"
delete:
operationId: deleteBoard
summary: Delete a board and its sections — the last board cannot be deleted
parameters:
- $ref: "#/components/parameters/id"
responses:
"204":
description: Deleted
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
/api/sections:
get:
operationId: listSections
summary: Full board — sections with nested items and urls, ordered
summary: Board tree — sections with nested items and urls, ordered
parameters:
- name: board
in: query
description: Restrict to one board id; omitted returns all sections
schema:
type: string
responses:
"200":
description: Ordered board tree
@@ -540,14 +618,70 @@ paths:
additionalProperties:
$ref: "#/components/schemas/Status"
/api/domains/{id}/subdomains:
get:
operationId: listSubdomains
summary: Discovered subdomains for a domain
parameters:
- $ref: "#/components/parameters/id"
responses:
"200":
description: Subdomain rows, name-sorted
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Subdomain"
"404":
$ref: "#/components/responses/NotFound"
/api/domains/{id}/subdomains/refresh:
post:
operationId: refreshSubdomains
summary: Force a crt.sh + DNS discovery run, returns the updated list
parameters:
- $ref: "#/components/parameters/id"
responses:
"200":
description: Subdomain rows after the sweep
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Subdomain"
"404":
$ref: "#/components/responses/NotFound"
/api/notify/test:
post:
operationId: notifyTest
summary: Send a test notification to the configured webhook
description: Requires the `notify_webhook` settings key to hold an http(s) URL.
summary: Send a test notification through configured transports
description: >-
Fires a test event synchronously through every configured notify_*
transport, or only the transport named in the body. Returns a
transport -> "ok" | "error: ..." map.
requestBody:
content:
application/json:
schema:
type: object
properties:
transport:
type: string
enum: [webhook, slack, discord, telegram, gotify, ntfy, smtp]
responses:
"200":
description: Test notification dispatched
description: Per-transport delivery results
content:
application/json:
schema:
type: object
properties:
results:
type: object
additionalProperties:
type: string
"400":
$ref: "#/components/responses/BadRequest"
@@ -1039,6 +1173,83 @@ paths:
"400":
$ref: "#/components/responses/BadRequest"
/api/auth/session:
get:
operationId: authSession
summary: Auth state — enabled flag, whether setup is needed, and current session
responses:
"200":
description: Session state
content:
application/json:
schema:
$ref: "#/components/schemas/AuthSession"
/api/auth/setup:
post:
operationId: authSetup
summary: Create the first local admin — only while no users exist
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, password]
properties:
username:
type: string
password:
type: string
minLength: 8
responses:
"201":
description: Admin created; session cookie set
"400":
$ref: "#/components/responses/BadRequest"
"409":
description: Setup already completed
/api/auth/login:
post:
operationId: authLogin
summary: Password login — sets a session cookie; rate-limited per IP
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, password]
properties:
username:
type: string
password:
type: string
responses:
"200":
description: Signed in
"401":
description: Invalid credentials
"429":
description: Too many attempts
/api/auth/logout:
post:
operationId: authLogout
summary: Destroy the current session
responses:
"204":
description: Signed out
/api/auth/disable:
post:
operationId: authDisable
summary: Turn authentication off — requires an active session
responses:
"204":
description: Disabled
components:
securitySchemes:
bearerAuth:
@@ -1074,6 +1285,19 @@ components:
status:
type: string
example: ok
AuthSession:
type: object
properties:
enabled:
type: boolean
setup:
type: boolean
description: True when auth is on but no user exists yet
authed:
type: boolean
username:
type: string
Error:
type: object
required: [error]
@@ -1172,7 +1396,7 @@ components:
$ref: "#/components/schemas/UrlInput"
Section:
type: object
required: [id, name, position, collapsed, items]
required: [id, name, position, collapsed, boardId, items]
properties:
id:
type: string
@@ -1182,16 +1406,32 @@ components:
type: number
collapsed:
type: boolean
boardId:
type: string
items:
type: array
items:
$ref: "#/components/schemas/Item"
Board:
type: object
required: [id, slug, name, position]
properties:
id:
type: string
slug:
type: string
name:
type: string
position:
type: number
SectionInput:
type: object
required: [name]
properties:
name:
type: string
boardId:
type: string
SectionPatch:
type: object
properties:
@@ -1285,6 +1525,8 @@ components:
type: string
notes:
type: string
alerts:
$ref: "#/components/schemas/AlertRules"
position:
type: number
lastCheck:
@@ -1294,6 +1536,22 @@ components:
createdAt:
type: string
format: date-time
AlertRules:
type: object
description: Per-target alert rules; omitted fields keep defaults.
properties:
consecutiveFailures:
type: integer
description: Checks must fail N times in a row before status flips down
latencyWarnMs:
type: integer
description: Notify once when an up check's ping crosses this threshold
certDays:
type: integer
description: Domain TLS expiry threshold; overrides alertDaysBefore
mute:
type: boolean
description: Suppress notifications; incidents/status still record
MonitorInput:
type: object
properties:
@@ -1338,6 +1596,8 @@ components:
type: string
notes:
type: string
alerts:
$ref: "#/components/schemas/AlertRules"
UptimeStats:
type: object
properties:
@@ -1519,6 +1779,8 @@ components:
type: string
format: date-time
nullable: true
alerts:
$ref: "#/components/schemas/AlertRules"
position:
type: number
createdAt:
@@ -1543,6 +1805,8 @@ components:
type: string
notes:
type: string
alerts:
$ref: "#/components/schemas/AlertRules"
DomainView:
allOf:
- $ref: "#/components/schemas/Domain"
@@ -1558,6 +1822,21 @@ components:
type: boolean
sslExpiring:
type: boolean
Subdomain:
type: object
properties:
name:
type: string
ips:
type: array
items:
type: string
firstSeen:
type: string
format: date-time
lastSeen:
type: string
format: date-time
DomainCheck:
type: object
properties:
@@ -1615,11 +1894,48 @@ components:
type: array
items:
$ref: "#/components/schemas/ContainerStat"
smart:
type: array
items:
$ref: "#/components/schemas/SmartDisk"
zfs:
type: array
items:
$ref: "#/components/schemas/ZFSPool"
gpu:
type: array
items:
$ref: "#/components/schemas/GPUStat"
ContainerStat:
type: object
properties:
name: { type: string }
state: { type: string }
cpu: { type: number, description: "percent of one core, may exceed 100" }
memUsed: { type: number, description: bytes }
memLimit: { type: number, description: bytes }
SmartDisk:
type: object
properties:
device: { type: string }
model: { type: string }
passed: { type: boolean, nullable: true }
tempC: { type: number }
ZFSPool:
type: object
properties:
name: { type: string }
health: { type: string }
size: { type: number, description: bytes }
free: { type: number, description: bytes }
GPUStat:
type: object
properties:
name: { type: string }
tempC: { type: number }
utilPct: { type: number }
memUsed: { type: number, description: bytes }
memTotal: { type: number, description: bytes }
System:
type: object
required: [id, name, token, status]
@@ -1819,3 +2135,7 @@ components:
type: array
items:
$ref: "#/components/schemas/Section"
boards:
type: array
items:
$ref: "#/components/schemas/Board"