mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
Merge pull request #3 from Dvorinka/feat/phase5
Phase 5: alerts, agent depth, subdomains, auth, boards, i18n
This commit is contained in:
82 files changed
+4903
-552
No files matched your search
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -99,19 +107,27 @@ func (s *Server) listSections(c *gin.Context) {
|
||||
|
||||
func (s *Server) createSection(c *gin.Context) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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
|
||||
}
|
||||
s.notify("test", "dash", "Dash test notification")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "sent"})
|
||||
c.JSON(http.StatusOK, gin.H{"results": results})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Whitespace-only changes.
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="notify-webhook"
|
||||
value={webhook}
|
||||
onChange={(e) => { setWebhook(e.target.value); setWebhookMsg(""); }}
|
||||
placeholder="https://ntfy.sh/dash or Slack/Discord hook"
|
||||
className="font-mono text-[12px]"
|
||||
<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 saveWebhook()}>
|
||||
Save
|
||||
<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="wallpaper"
|
||||
value={board.wallpaper}
|
||||
onChange={(e) => void board.setSetting("wallpaper", e.target.value)}
|
||||
placeholder={t("settings.wallpaperPh")}
|
||||
className="font-mono text-[12px]"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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/<slug></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/<slug></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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
+325
-5
@@ -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"
|
||||
Reference in new issue
Block a user