Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
009652ce3e | ||
|
|
7fd88c0212 | ||
|
|
2f1b5f5cf1 | ||
|
|
ef55029fd2 | ||
|
|
b2371bbb8b | ||
|
|
63b1c4a26b | ||
|
|
09030afa38 | ||
|
|
d5cf428c89 | ||
|
|
31cab9b24b | ||
|
|
70f3ee9116 |
@@ -0,0 +1,157 @@
|
|||||||
|
# Dash — uptime observatory
|
||||||
|
|
||||||
|
A self-hosted uptime monitoring dashboard in a single binary. A Go check
|
||||||
|
engine with SQLite persistence streams live status over SSE into a
|
||||||
|
hand-built React frontend: three bespoke SVG circular visualizations with
|
||||||
|
spring physics, full RTL (Farsi) support, and a shareable public status mode.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Five check types** — HTTP (status + latency), TCP port, TLS certificate
|
||||||
|
expiry (days remaining), ICMP ping, and Docker (container running +
|
||||||
|
healthcheck status via the daemon socket). Each target runs on its own
|
||||||
|
interval.
|
||||||
|
- **Flap detection** — N consecutive failures before a target is marked
|
||||||
|
down, M consecutive successes before it recovers. No alert noise from a
|
||||||
|
single dropped packet.
|
||||||
|
- **Incident grouping** — one incident spans the entire down period, from
|
||||||
|
the first failed check of the streak until recovery.
|
||||||
|
- **Live stream** — `/api/stream` pushes every probe and every confirmed
|
||||||
|
state transition over Server-Sent Events. The UI never polls.
|
||||||
|
- **Every result persisted** — timestamp, latency, status, error string in
|
||||||
|
SQLite (pure Go driver, no CGO), pruned after `history_days`.
|
||||||
|
|
||||||
|
## The frontend
|
||||||
|
|
||||||
|
Three circular instruments, all hand-built SVG + Framer Motion (no charting
|
||||||
|
libraries):
|
||||||
|
|
||||||
|
1. **Status rings** — one per target. The outer arc fills to the uptime
|
||||||
|
percentage over the selected window (24H/7D) and springs to every new
|
||||||
|
value; the inner brass gauge encodes latency on a log scale (1ms–2.5s);
|
||||||
|
each completed probe lands as a visible pulse; confirmed state changes
|
||||||
|
ripple outward. Click a ring and it travels into a detail view (shared
|
||||||
|
layout transition) with a latency sparkline, a per-target day dial, and
|
||||||
|
recent incidents.
|
||||||
|
2. **Orbital overview** — every target orbits the system core at
|
||||||
|
Kepler-flavored speeds (inner orbits run faster). Healthy bodies drift
|
||||||
|
calmly with motion trails, degraded ones breathe, a down body breaks its
|
||||||
|
orbit outward and flares red over the scar of its old path. The core
|
||||||
|
carries fleet-wide uptime and a brass scanner hand.
|
||||||
|
3. **Day dial** — a 24-hour clock face. Each of the last 24 hours is one
|
||||||
|
segment at its true wall-clock position, colored by status. Hours
|
||||||
|
without data stay hairline-thin. A brass hand marks now. One glance, one
|
||||||
|
full day.
|
||||||
|
|
||||||
|
Plus: a boot sequence where the instruments draw themselves in, staggered;
|
||||||
|
`prefers-reduced-motion` collapses all motion to fully-formed static
|
||||||
|
rendering; **فارسی / RTL** is a first-class layout mode (mirrored layout,
|
||||||
|
Vazirmatn type, Persian digits and the Persian solar calendar via `fa-IR`);
|
||||||
|
**dark and light themes** (persisted, both tuned variants of the same brass
|
||||||
|
/ ink design language); and a **Status** mode — the same instruments
|
||||||
|
stripped to a clean, shareable public status page.
|
||||||
|
|
||||||
|
Continuous motion (the orbital field, the scanner hand) runs outside React
|
||||||
|
entirely — one animation-frame loop mutates SVG attributes directly, so the
|
||||||
|
page stays at full frame rate regardless of target count. No SVG filters,
|
||||||
|
no blend modes on hot layers.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requires Go ≥ 1.22 and Node ≥ 20. The frontend must be built first because
|
||||||
|
the binary embeds it via `go:embed`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. build the frontend into web/dist
|
||||||
|
cd web && npm install && npm run build && cd ..
|
||||||
|
|
||||||
|
# 2. compile the single binary (web/dist is embedded)
|
||||||
|
go build -o dash .
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is one self-contained file, `dash`. Copy it and a `targets.yaml`
|
||||||
|
to any Linux/macOS/Windows box — nothing else to install.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./dash # uses ./targets.yaml, ./dash.db, :8080
|
||||||
|
./dash -config /etc/dash/targets.yaml -db /var/lib/dash.db -listen :9090
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8080.
|
||||||
|
|
||||||
|
> **ICMP checks** need raw-socket privileges. Run as root, or grant the
|
||||||
|
> binary `CAP_NET_RAW` (`setcap cap_net_raw+ep ./dash`), or allow
|
||||||
|
> unprivileged ping (`sysctl net.ipv4.ping_group_range="0 2147483647"`).
|
||||||
|
> Other check types work without any privileges.
|
||||||
|
|
||||||
|
## Configuration — targets.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
listen: ":8080" # bind address
|
||||||
|
db: "dash.db" # sqlite file
|
||||||
|
history_days: 30 # raw check retention
|
||||||
|
|
||||||
|
targets:
|
||||||
|
- id: example-web # unique slug (required)
|
||||||
|
name: "Example Website"
|
||||||
|
type: http # http | tcp | tls | icmp
|
||||||
|
url: https://example.com # http → url, tcp/tls → address, icmp → host
|
||||||
|
interval: 15s # time between checks (default 30s)
|
||||||
|
timeout: 5s # per-check timeout (default 5s)
|
||||||
|
fail_threshold: 3 # consecutive fails → down (default 3)
|
||||||
|
recover_threshold: 2 # consecutive oks → up (default 2)
|
||||||
|
expect_status: 200 # http only: require exact status
|
||||||
|
degraded_latency_ms: 1500 # mark degraded above this latency
|
||||||
|
|
||||||
|
- id: github-tls
|
||||||
|
name: "GitHub TLS"
|
||||||
|
type: tls
|
||||||
|
address: github.com:443
|
||||||
|
interval: 5m
|
||||||
|
warn_days: 21 # tls only: degraded when cert expires sooner
|
||||||
|
|
||||||
|
- id: my-container
|
||||||
|
name: "My Container"
|
||||||
|
type: docker
|
||||||
|
container: my-container # docker container name
|
||||||
|
interval: 30s
|
||||||
|
# socket: /var/run/docker.sock (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Docker checks** read the daemon socket. Run the binary as a user in the
|
||||||
|
> `docker` group (or `SupplementaryGroups=docker` in the systemd unit). A
|
||||||
|
> container is *up* when running, *degraded* while its healthcheck reports
|
||||||
|
> `starting`, and failing when stopped or `unhealthy`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/targets` | Current state of all targets: flap-filtered status, last check, 24h/7d uptime, latency percentiles, open incident |
|
||||||
|
| `GET /api/targets/{id}/history?range=24h` | Time series: downsampled points plus 24 aggregate buckets (`range`: `30m`, `24h`, `7d`, …) |
|
||||||
|
| `GET /api/incidents?range=7d` | Grouped incidents, ongoing first |
|
||||||
|
| `GET /api/stream` | SSE: `snapshot` on connect, then `check` per probe and `state` per confirmed transition |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run . & # backend on :8080
|
||||||
|
cd web && npm run dev # vite on :5173, proxies /api → :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
main.go, webfs.go entrypoint + embedded frontend
|
||||||
|
internal/config targets.yaml parsing, defaults, validation
|
||||||
|
internal/checker the four probe implementations
|
||||||
|
internal/engine scheduler, flap detection, incidents, SSE hub
|
||||||
|
internal/store sqlite persistence (modernc.org/sqlite, no CGO)
|
||||||
|
internal/api REST + SSE handlers
|
||||||
|
web/ React + TypeScript + Vite + Tailwind v4 + Motion
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 793 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 559 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#13110e"/>
|
||||||
|
<circle cx="16" cy="16" r="10" fill="none" stroke="#2b2722" stroke-width="1.5"/>
|
||||||
|
<path d="M 16 6 A 10 10 0 1 1 7.34 21" fill="none" stroke="#d9aa54" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="16" cy="16" r="3" fill="#d9aa54"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 369 B |
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,97 @@
|
|||||||
|
export type Lang = "en" | "fa";
|
||||||
|
|
||||||
|
const en = {
|
||||||
|
tagline: "uptime observatory",
|
||||||
|
allOperational: "All systems operational",
|
||||||
|
someDegraded: "Degraded performance",
|
||||||
|
outage: "Active outage",
|
||||||
|
pending: "Awaiting first checks",
|
||||||
|
targets: "Targets",
|
||||||
|
incidents: "Incidents",
|
||||||
|
ongoing: "ongoing",
|
||||||
|
resolved: "resolved",
|
||||||
|
uptime: "uptime",
|
||||||
|
latency: "latency",
|
||||||
|
lastCheck: "last check",
|
||||||
|
interval: "every",
|
||||||
|
live: "live",
|
||||||
|
connecting: "connecting",
|
||||||
|
systemDay: "last 24 hours",
|
||||||
|
noIncidents: "No incidents in this window",
|
||||||
|
noData: "no data",
|
||||||
|
checks: "checks",
|
||||||
|
failed: "failed",
|
||||||
|
certExpiry: "cert expires in",
|
||||||
|
daysShort: "d",
|
||||||
|
internalMode: "Console",
|
||||||
|
publicMode: "Status",
|
||||||
|
overall: "overall",
|
||||||
|
endpoint: "endpoint",
|
||||||
|
recentIncidents: "recent incidents",
|
||||||
|
close: "Close",
|
||||||
|
now: "now",
|
||||||
|
duration: "duration",
|
||||||
|
state: {
|
||||||
|
up: "operational",
|
||||||
|
degraded: "degraded",
|
||||||
|
down: "down",
|
||||||
|
unknown: "pending",
|
||||||
|
} as Record<string, string>,
|
||||||
|
orbit: "system orbit",
|
||||||
|
dial: "day dial",
|
||||||
|
all: "all",
|
||||||
|
themeLight: "Switch to light theme",
|
||||||
|
themeDark: "Switch to dark theme",
|
||||||
|
openEndpoint: "Open endpoint",
|
||||||
|
uptime30: "uptime 30d",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fa: typeof en = {
|
||||||
|
tagline: "رصدخانهٔ پایداری",
|
||||||
|
allOperational: "همهٔ سامانهها پایدارند",
|
||||||
|
someDegraded: "افت کارایی",
|
||||||
|
outage: "قطعی فعال",
|
||||||
|
pending: "در انتظار نخستین بررسی",
|
||||||
|
targets: "هدفها",
|
||||||
|
incidents: "رخدادها",
|
||||||
|
ongoing: "در جریان",
|
||||||
|
resolved: "برطرفشده",
|
||||||
|
uptime: "دسترسپذیری",
|
||||||
|
latency: "تأخیر",
|
||||||
|
lastCheck: "آخرین بررسی",
|
||||||
|
interval: "هر",
|
||||||
|
live: "زنده",
|
||||||
|
connecting: "در حال اتصال",
|
||||||
|
systemDay: "۲۴ ساعت گذشته",
|
||||||
|
noIncidents: "رخدادی در این بازه نیست",
|
||||||
|
noData: "بدون داده",
|
||||||
|
checks: "بررسی",
|
||||||
|
failed: "ناموفق",
|
||||||
|
certExpiry: "اعتبار گواهی",
|
||||||
|
daysShort: " روز",
|
||||||
|
internalMode: "کنسول",
|
||||||
|
publicMode: "وضعیت",
|
||||||
|
overall: "کلی",
|
||||||
|
endpoint: "نشانی",
|
||||||
|
recentIncidents: "رخدادهای اخیر",
|
||||||
|
close: "بستن",
|
||||||
|
now: "اکنون",
|
||||||
|
duration: "مدت",
|
||||||
|
state: {
|
||||||
|
up: "پایدار",
|
||||||
|
degraded: "افتکرده",
|
||||||
|
down: "قطع",
|
||||||
|
unknown: "در انتظار",
|
||||||
|
},
|
||||||
|
orbit: "مدار سامانه",
|
||||||
|
dial: "صفحهٔ شبانهروز",
|
||||||
|
all: "همه",
|
||||||
|
themeLight: "پوستهٔ روشن",
|
||||||
|
themeDark: "پوستهٔ تیره",
|
||||||
|
openEndpoint: "باز کردن نشانی",
|
||||||
|
uptime30: "دسترسپذیری ۳۰ روزه",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STRINGS: Record<Lang, typeof en> = { en, fa };
|
||||||
|
|
||||||
|
export const localeOf = (lang: Lang) => (lang === "fa" ? "fa-IR" : "en-US");
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
export type TargetState = "up" | "degraded" | "down" | "unknown";
|
||||||
|
|
||||||
|
export interface CheckPoint {
|
||||||
|
ts: number;
|
||||||
|
ok: boolean;
|
||||||
|
latency_ms: number;
|
||||||
|
detail?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatencyStats {
|
||||||
|
avg_ms: number;
|
||||||
|
p50_ms: number;
|
||||||
|
p95_ms: number;
|
||||||
|
min_ms: number;
|
||||||
|
max_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Incident {
|
||||||
|
id: number;
|
||||||
|
target_id: string;
|
||||||
|
started_at: number;
|
||||||
|
ended_at?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckKind = "http" | "tcp" | "tls" | "icmp" | "docker";
|
||||||
|
|
||||||
|
export interface Target {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: CheckKind;
|
||||||
|
endpoint: string;
|
||||||
|
interval_sec: number;
|
||||||
|
state: TargetState;
|
||||||
|
fails: number;
|
||||||
|
last?: CheckPoint;
|
||||||
|
uptime_24h?: number;
|
||||||
|
uptime_7d?: number;
|
||||||
|
uptime_30d?: number;
|
||||||
|
latency: LatencyStats;
|
||||||
|
cert_days?: number;
|
||||||
|
incident?: Incident;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bucket {
|
||||||
|
start: number;
|
||||||
|
total: number;
|
||||||
|
failed: number;
|
||||||
|
avg_latency_ms: number;
|
||||||
|
max_latency_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryResponse {
|
||||||
|
target_id: string;
|
||||||
|
range: string;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
points: CheckPoint[];
|
||||||
|
buckets: Bucket[];
|
||||||
|
bucket_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckEvent {
|
||||||
|
target_id: string;
|
||||||
|
ts: number;
|
||||||
|
ok: boolean;
|
||||||
|
latency_ms: number;
|
||||||
|
detail?: string;
|
||||||
|
error?: string;
|
||||||
|
state: TargetState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateEvent {
|
||||||
|
target_id: string;
|
||||||
|
ts: number;
|
||||||
|
state: TargetState;
|
||||||
|
prev_state: TargetState;
|
||||||
|
incident?: Incident;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A target enriched with live, client-side stream state. */
|
||||||
|
export interface LiveTarget extends Target {
|
||||||
|
/** Increments on every completed probe — drives the pulse animation. */
|
||||||
|
pulse: number;
|
||||||
|
/** Wall time of the last confirmed state transition — drives ripples. */
|
||||||
|
stateChangedAt: number;
|
||||||
|
/** Rolling buffer of recent checks for the sparkline. */
|
||||||
|
series: CheckPoint[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useReducedMotion } from "motion/react";
|
||||||
|
import { STRINGS, type Lang } from "./i18n";
|
||||||
|
|
||||||
|
export type ViewMode = "internal" | "public";
|
||||||
|
export type Window = "24h" | "7d";
|
||||||
|
export type Theme = "dark" | "light";
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
lang: Lang;
|
||||||
|
dir: "ltr" | "rtl";
|
||||||
|
mode: ViewMode;
|
||||||
|
window: Window;
|
||||||
|
theme: Theme;
|
||||||
|
reduced: boolean;
|
||||||
|
t: (typeof STRINGS)["en"];
|
||||||
|
setLang: (l: Lang) => void;
|
||||||
|
setMode: (m: ViewMode) => void;
|
||||||
|
setWindow: (w: Window) => void;
|
||||||
|
setTheme: (t: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIContext = createContext<UIState | null>(null);
|
||||||
|
|
||||||
|
export function UIProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [lang, setLang] = useState<Lang>(
|
||||||
|
() => (localStorage.getItem("dash.lang") as Lang) || "en",
|
||||||
|
);
|
||||||
|
const [mode, setMode] = useState<ViewMode>(
|
||||||
|
() => (localStorage.getItem("dash.mode") as ViewMode) || "internal",
|
||||||
|
);
|
||||||
|
const [window, setWindow] = useState<Window>("24h");
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (localStorage.getItem("dash.theme") as Theme) || "dark",
|
||||||
|
);
|
||||||
|
const reduced = useReducedMotion() ?? false;
|
||||||
|
|
||||||
|
const dir = lang === "fa" ? "rtl" : "ltr";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dir = dir;
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
localStorage.setItem("dash.lang", lang);
|
||||||
|
}, [lang, dir]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
localStorage.setItem("dash.theme", theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("dash.mode", mode);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
const value = useMemo<UIState>(
|
||||||
|
() => ({
|
||||||
|
lang,
|
||||||
|
dir,
|
||||||
|
mode,
|
||||||
|
window,
|
||||||
|
theme,
|
||||||
|
reduced,
|
||||||
|
t: STRINGS[lang],
|
||||||
|
setLang,
|
||||||
|
setMode,
|
||||||
|
setWindow,
|
||||||
|
setTheme,
|
||||||
|
}),
|
||||||
|
[lang, dir, mode, window, theme, reduced],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <UIContext.Provider value={value}>{children}</UIContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUI(): UIState {
|
||||||
|
const ctx = useContext(UIContext);
|
||||||
|
if (!ctx) throw new Error("useUI outside UIProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spring presets — physical, interruptible; inert under reduced motion. */
|
||||||
|
export const springs = {
|
||||||
|
arc: { type: "spring", stiffness: 48, damping: 16, mass: 1.2 } as const,
|
||||||
|
snappy: { type: "spring", stiffness: 360, damping: 28 } as const,
|
||||||
|
gentle: { type: "spring", stiffness: 120, damping: 22 } as const,
|
||||||
|
layout: { type: "spring", stiffness: 230, damping: 26 } as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATE_COLOR: Record<string, string> = {
|
||||||
|
up: "var(--color-up)",
|
||||||
|
degraded: "var(--color-warn)",
|
||||||
|
down: "var(--color-down)",
|
||||||
|
unknown: "var(--color-idle)",
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The built frontend is compiled into the binary, so deployment is one file.
|
||||||
|
//
|
||||||
|
//go:embed all:web/dist
|
||||||
|
var webFS embed.FS
|
||||||
|
|
||||||
|
func webHandler() http.Handler {
|
||||||
|
dist, err := fs.Sub(webFS, "web/dist")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fileServer := http.FileServer(http.FS(dist))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||||
|
if p != "" {
|
||||||
|
if _, err := fs.Stat(dist, p); err != nil {
|
||||||
|
// SPA fallback: unknown paths serve the app shell.
|
||||||
|
r.URL.Path = "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||