diff --git a/.gitignore b/.gitignore index 59873fa..7ff0525 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ epmon.db* config.yaml config.json data/ +/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b1c87f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,135 @@ +# epmon — single static binary, zero cgo. +# +# make build local binary with version stamping +# make test full suite (make test-race for -race, as in CI) +# make check fmt + vet + tests (what CI's go job runs) +# make cross §G1 matrix into dist/ +# make docker-build image with release metadata (never reports `dev`) +# make compose-up detached stack (VERSION/COMMIT/DATE flow through) +# +# Overridables: make build VERSION=0.2.0 / make compose-up VERSION=... / ARGS="..." + +BINARY ?= epmon +DIST ?= dist +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE ?= $(shell date -u +%FT%TZ 2>/dev/null || echo unknown) +LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) +GO ?= go +DOCKER ?= docker +# `docker compose` (plugin) or falling back to the standalone binary. +COMPOSE ?= $(shell if $(DOCKER) compose version >/dev/null 2>&1; then echo "$(DOCKER) compose"; else echo docker-compose; fi) + +PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 windows/amd64 + +.PHONY: help build test test-race vet fmt lint check check-docs cross clean \ + run init validate web docker-build docker-version docker-run \ + compose-up compose-down compose-logs compose-config + +help: + @echo "Targets:" + @echo " build CGO_ENABLED=0 binary ./$(BINARY) with ldflags stamping" + @echo " test go test ./..." + @echo " test-race go test -race ./... (CI parity)" + @echo " vet go vet ./..." + @echo " fmt fail on gofmt drift" + @echo " lint golangci-lint if installed, else warn-and-skip" + @echo " check fmt + vet + test-race" + @echo " check-docs every README yaml block + examples must validate" + @echo " cross static binaries for $(PLATFORMS) into $(DIST)/" + @echo " clean remove ./$(BINARY) and $(DIST)/" + @echo ' run go run ./cmd/epmon run $$ARGS' + @echo ' init go run ./cmd/epmon init $$ARGS' + @echo ' validate go run ./cmd/epmon validate $$ARGS' + @echo ' web rebuild + vendor the status SPA (STATUS_DIR=../status)' + @echo ' docker-build image epmon:$$VERSION with release metadata' + @echo " docker-version print the image's reported version" + @echo " docker-run run image foreground (override with ARGS)" + @echo " compose-up detached stack (passes VERSION/COMMIT/DATE)" + @echo " compose-down stop the stack" + @echo " compose-logs tail the stack logs" + @echo " compose-config validate compose file parsing" + +build: + CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/epmon + +test: + $(GO) test ./... + +test-race: + $(GO) test -race ./... + +vet: + $(GO) vet ./... + +fmt: + @test -z "$$($(GO)fmt -l .)" || { $(GO)fmt -l .; echo "gofmt drift — run: gofmt -w ."; exit 1; } + +lint: + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run ./...; \ + else \ + echo "golangci-lint not installed — skipping (vet still runs in check)"; \ + fi + +check: fmt vet test-race + +# Mirrors CI: every ```yaml block in README must load through validate, +# plus the shipped examples. (Script file, not a heredoc: macOS make is +# 3.81 and runs each recipe line in its own shell.) +check-docs: + EPMON_API_KEY=ci-dummy TOKEN=ci-dummy DEV_TOKEN=ci-dummy python3 scripts/check-docs.py + +cross: + @mkdir -p $(DIST) + @for p in $(PLATFORMS); do \ + os=$${p%/*}; arch=$${p#*/}; out=$(DIST)/epmon-$$os-$$arch; \ + test "$$os" = windows && out=$$out.exe; \ + echo "building $$out"; \ + CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch $(GO) build -trimpath \ + -ldflags "$(LDFLAGS)" -o $$out ./cmd/epmon || exit 1; \ + done + +clean: + rm -f $(BINARY) + rm -rf $(DIST) + +run: + $(GO) run ./cmd/epmon run $(ARGS) + +init: + $(GO) run ./cmd/epmon init $(ARGS) + +validate: + $(GO) run ./cmd/epmon validate $(ARGS) + +# Rebuild the status SPA from a status/ checkout (default: ../status) +# and vendor it into internal/api/webui. Origin is stamped in source.txt. +web: + STATUS_DIR="$${STATUS_DIR:-../status}" sh scripts/refresh-webui.sh + +docker-build: + $(DOCKER) build -t epmon:$(VERSION) \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + --build-arg DATE=$(DATE) . + +docker-version: + $(DOCKER) run --rm --entrypoint /epmon epmon:$(VERSION) version + +docker-run: + $(DOCKER) run --rm -p 8080:8080 \ + -v "$(CURDIR)/data:/data:rw" \ + epmon:$(VERSION) $(ARGS) + +compose-up: + VERSION=$(VERSION) COMMIT=$(COMMIT) DATE=$(DATE) $(COMPOSE) up --build -d + +compose-down: + $(COMPOSE) down + +compose-logs: + $(COMPOSE) logs -f + +compose-config: + $(COMPOSE) config diff --git a/cmd/epmon/init.go b/cmd/epmon/init.go new file mode 100644 index 0000000..896f308 --- /dev/null +++ b/cmd/epmon/init.go @@ -0,0 +1,666 @@ +package main + +// Command epmon init generates a config file through the same schema the +// loader validates (spec §17.2): interactive prompt loop by default, flags +// with --non-interactive for scripts. Generation builds a YAML document, +// validates it through config.Parse (the loader path), and writes it +// atomically. Invalid documents are never written; existing files are +// never overwritten without --force; secrets are written as ${VAR} +// references, never literally. +// +// Usage: +// epmon init [--output epmon.yaml] [--force] +// epmon init --non-interactive [--output epmon.yaml] [--force] +// [--server-listen :8080] [--db-dsn epmon.db] [--retention-days 90] +// [--default-interval 60s] [--default-timeout 10s] [--failure-threshold 1] +// --service id=url[;key=value...] ... + +import ( + "bufio" + "bytes" + "flag" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/epmon-dev/epmon/internal/config" + "gopkg.in/yaml.v3" +) + +// initService holds one service as collected; empty interval/timeout mean +// "inherit the probe defaults", empty name means "same as id". +type initService struct { + id, name, url string + interval, timeout string + expect, body string + headers [][2]string +} + +// initDoc is the collected answers before rendering. +type initDoc struct { + output string + listen, dsn string + retention, threshold int + defInterval, defTimeout string + services []initService +} + +// serviceFlag accumulates repeatable --service values. +type serviceFlag []string + +func (s *serviceFlag) String() string { return strings.Join(*s, ", ") } +func (s *serviceFlag) Set(v string) error { *s = append(*s, v); return nil } + +// initCommand runs epmon init. Exit codes: 0 wrote a valid file, 1 the +// document was invalid/refused/aborted, 64 usage. +func initCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + fs.SetOutput(stderr) + output := fs.String("output", "epmon.yaml", "destination file") + force := fs.Bool("force", false, "overwrite an existing file") + nonInteractive := fs.Bool("non-interactive", false, "drive from flags, no prompts") + listen := fs.String("server-listen", ":8080", "server.listen") + dsn := fs.String("db-dsn", "epmon.db", "sqlite database file") + retention := fs.Int("retention-days", 90, "database.retention_days") + defInterval := fs.String("default-interval", "60s", "probes.default_interval") + defTimeout := fs.String("default-timeout", "10s", "probes.default_timeout") + threshold := fs.Int("failure-threshold", 1, "probes.failure_threshold") + var services serviceFlag + fs.Var(&services, "service", "id=url[;key=value...]; repeatable (keys: name,interval,timeout,expect,body_contains)") + if err := fs.Parse(args); err != nil { + return exitUsage + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "epmon: init takes no positional args\n") + return exitUsage + } + + set := map[string]bool{} + fs.Visit(func(f *flag.Flag) { set[f.Name] = true }) + + var doc *initDoc + var err error + if *nonInteractive { + doc, err = initFromFlags(*output, *listen, *dsn, *retention, *defInterval, *defTimeout, *threshold, services) + } else { + // Interactive honors --output/--force only; content flags imply + // the user meant --non-interactive. + for name := range set { + if name != "output" && name != "force" { + fmt.Fprintf(stderr, "epmon: init flag --%s needs --non-interactive (or run without flags for prompts)\n", name) + return exitUsage + } + } + doc, err = initInteractive(stdin, stdout, *output) + } + if err != nil { + fmt.Fprintf(stderr, "epmon: init: %v\n", err) + return exitConfig + } + if err := writeInitDoc(doc, *force, stdout); err != nil { + fmt.Fprintf(stderr, "epmon: init: %v\n", err) + return exitConfig + } + return exitOK +} + +// ---- non-interactive ---- + +// initFromFlags builds the document purely from flag values. Zero +// --service flags is fatal: a scripted run with no services is a +// scripting bug, and the loader rejects zero-service files anyway. +func initFromFlags(output, listen, dsn string, retention int, defInterval, defTimeout string, threshold int, services serviceFlag) (*initDoc, error) { + if len(services) == 0 { + return nil, fmt.Errorf("--non-interactive needs at least one --service (config files with zero services are invalid)") + } + doc := &initDoc{ + output: output, listen: listen, dsn: dsn, + retention: retention, defInterval: defInterval, + defTimeout: defTimeout, threshold: threshold, + } + for _, raw := range services { + svc, err := parseServiceFlag(raw) + if err != nil { + return nil, err + } + doc.services = append(doc.services, svc) + } + if err := checkInitScalars(doc); err != nil { + return nil, err + } + return doc, nil +} + +// parseServiceFlag parses id=url[;key=value...]. Keys: name, interval, +// timeout, expect, body_contains. Headers are interactive-only. +func parseServiceFlag(raw string) (initService, error) { + var svc initService + head, rest, _ := strings.Cut(raw, ";") + id, target, ok := strings.Cut(head, "=") + svc.id = strings.TrimSpace(id) + svc.url = strings.TrimSpace(target) + if !ok || svc.id == "" || svc.url == "" { + return svc, fmt.Errorf("--service %q must look like id=https://host/path[;key=value...]", raw) + } + for _, kv := range strings.Split(rest, ";") { + if kv == "" { + continue + } + k, v, ok := strings.Cut(kv, "=") + if !ok { + return svc, fmt.Errorf("--service %q: %q needs key=value", raw, kv) + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + switch k { + case "name": + svc.name = v + case "interval": + svc.interval = v + case "timeout": + svc.timeout = v + case "expect": + svc.expect = v + case "body_contains": + svc.body = v + default: + return svc, fmt.Errorf("--service %q: unknown key %q (want name|interval|timeout|expect|body_contains)", raw, k) + } + } + return svc, nil +} + +// checkInitScalars fails fast on malformed numbers/durations/URLs so a +// script gets a clear error; ranges remain the loader's job at the gate. +func checkInitScalars(doc *initDoc) error { + if doc.retention < 1 { + return fmt.Errorf("retention-days must be >= 1") + } + if doc.threshold < 1 { + return fmt.Errorf("failure-threshold must be >= 1") + } + for _, d := range []string{doc.defInterval, doc.defTimeout} { + if _, err := time.ParseDuration(d); err != nil { + return fmt.Errorf("bad duration %q: %v", d, err) + } + } + for i, s := range doc.services { + if err := checkInitService(s); err != nil { + return fmt.Errorf("service %d: %v", i, err) + } + } + return nil +} + +func checkInitService(s initService) error { + if _, err := time.ParseDuration(orDefault(s.interval, "60s")); err != nil { + return fmt.Errorf("bad interval %q: %v", s.interval, err) + } + if _, err := time.ParseDuration(orDefault(s.timeout, "10s")); err != nil { + return fmt.Errorf("bad timeout %q: %v", s.timeout, err) + } + if _, err := parseExpect(orDefault(s.expect, "200")); err != nil { + return fmt.Errorf("bad expect %q: %v", s.expect, err) + } + return checkURL(s.url) +} + +func checkURL(raw string) error { + u, err := url.ParseRequestURI(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("url must be absolute http(s), got %q", raw) + } + return nil +} + +func orDefault(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +// ---- interactive ---- + +// prompter reads answer lines; EOF aborts the run with errAbort. +type prompter struct { + sc *bufio.Scanner + out io.Writer +} + +var errAbort = fmt.Errorf("aborted (no file written)") + +func (p *prompter) ask(prompt, def string) (string, error) { + if def != "" { + fmt.Fprintf(p.out, "%s [%s]: ", prompt, def) + } else { + fmt.Fprintf(p.out, "%s: ", prompt) + } + if !p.sc.Scan() { + if err := p.sc.Err(); err != nil { + return "", err + } + return "", errAbort + } + ans := strings.TrimSpace(p.sc.Text()) + if ans == "" { + return def, nil + } + return ans, nil +} + +func (p *prompter) askNonEmpty(prompt string) (string, error) { + for { + ans, err := p.ask(prompt, "") + if err != nil { + return "", err + } + if ans != "" { + return ans, nil + } + } +} + +// initInteractive walks the prompts. A first empty service id re-prompts +// (zero-service files are invalid); EOF anywhere aborts with no output. +func initInteractive(stdin io.Reader, stdout io.Writer, output string) (*initDoc, error) { + p := &prompter{sc: bufio.NewScanner(stdin), out: stdout} + p.sc.Buffer(make([]byte, 64*1024), 1024*1024) + fmt.Fprintln(stdout, "epmon init — answer prompts, empty takes [default]. Ctrl-C aborts.") + + doc := &initDoc{output: output} + var err error + if doc.listen, err = p.ask("Listen address", ":8080"); err != nil { + return nil, err + } + if doc.dsn, err = p.ask("SQLite database file", "epmon.db"); err != nil { + return nil, err + } + retention, err := askInt(p, "Retention days", 90, 1, 0) + if err != nil { + return nil, err + } + doc.retention = retention + if doc.defInterval, err = askDuration(p, "Default probe interval", "60s"); err != nil { + return nil, err + } + if doc.defTimeout, err = askDuration(p, "Default probe timeout", "10s"); err != nil { + return nil, err + } + threshold, err := askInt(p, "Failure threshold", 1, 1, 0) + if err != nil { + return nil, err + } + doc.threshold = threshold + + for { + id, err := p.ask("Service id (empty when done)", "") + if err != nil { + return nil, err + } + if id == "" { + if len(doc.services) == 0 { + fmt.Fprintln(stdout, "At least one service is required — a config with zero services is invalid.") + continue + } + break + } + svc, err := askService(p, id) + if err != nil { + return nil, err + } + doc.services = append(doc.services, svc) + } + return doc, nil +} + +func askInt(p *prompter, prompt string, def, min, max int) (int, error) { + for { + ans, err := p.ask(prompt, strconv.Itoa(def)) + if err != nil { + return 0, err + } + n, err := strconv.Atoi(ans) + if err != nil || n < min || (max > 0 && n > max) { + if max > 0 { + fmt.Fprintf(p.out, "Enter a number %d..%d.\n", min, max) + } else { + fmt.Fprintf(p.out, "Enter a number >= %d.\n", min) + } + continue + } + return n, nil + } +} + +func askDuration(p *prompter, prompt, def string) (string, error) { + for { + ans, err := p.ask(prompt, def) + if err != nil { + return "", err + } + if _, err := time.ParseDuration(ans); err != nil { + fmt.Fprintf(p.out, "Bad duration %q (try 30s, 5m).\n", ans) + continue + } + return ans, nil + } +} + +// askService collects one service; id was already given. URL and expect +// re-prompt on errors; ranges are enforced by the validate gate. +func askService(p *prompter, id string) (initService, error) { + svc := initService{id: id} + var err error + if svc.name, err = p.ask(" Name", id); err != nil { + return svc, err + } + for { + if svc.url, err = p.askNonEmpty(" URL"); err != nil { + return svc, err + } + if err := checkURL(svc.url); err != nil { + fmt.Fprintf(p.out, " %v.\n", err) + continue + } + break + } + if svc.interval, err = askOptionalDuration(p, " Interval (empty = default)"); err != nil { + return svc, err + } + if svc.timeout, err = askOptionalDuration(p, " Timeout (empty = default)"); err != nil { + return svc, err + } + for { + ans, err := p.ask(" Expected status (200, 2xx, 200,301)", "200") + if err != nil { + return svc, err + } + if _, err := parseExpect(ans); err != nil { + fmt.Fprintf(p.out, " %v.\n", err) + continue + } + svc.expect = ans + break + } + if svc.body, err = p.ask(" Body must contain (empty = skip)", ""); err != nil { + return svc, err + } + for { + h, err := p.ask(` Header "Name: value" ($NAME keeps a secret out of the file, empty when done)`, "") + if err != nil { + return svc, err + } + if h == "" { + break + } + name, value, ok := strings.Cut(h, ":") + name, value = strings.TrimSpace(name), strings.TrimSpace(value) + if !ok || name == "" || value == "" { + fmt.Fprintln(p.out, ` Give "Name: value" or leave empty.`) + continue + } + svc.headers = append(svc.headers, [2]string{name, secretRef(value)}) + } + return svc, nil +} + +// askOptionalDuration is askDuration with blank allowed (inherit). +func askOptionalDuration(p *prompter, prompt string) (string, error) { + for { + fmt.Fprintf(p.out, "%s: ", prompt) + if !p.sc.Scan() { + if err := p.sc.Err(); err != nil { + return "", err + } + return "", errAbort + } + ans := strings.TrimSpace(p.sc.Text()) + if ans == "" { + return "", nil + } + if _, err := time.ParseDuration(ans); err != nil { + fmt.Fprintf(p.out, "Bad duration %q (try 30s, 5m).\n", ans) + continue + } + return ans, nil + } +} + +// secretRef rewrites bare $NAME references to ${NAME} anywhere in the +// value — the substitution grammar (§4.2) only expands braced forms, so +// `Bearer $TOKEN` would otherwise reach the runtime literally. $$ +// (literal $) and ${...} forms pass through untouched. +var bareRefRe = regexp.MustCompile(`\$(\$|[A-Za-z_][A-Za-z0-9_]*)`) + +func secretRef(v string) string { + return bareRefRe.ReplaceAllStringFunc(v, func(m string) string { + if m == "$$" { + return m + } + return "${" + m[1:] + "}" + }) +} + +// parseExpect validates expect_status input through the loader's own +// §4.3.1 parser and returns the YAML node to emit: a bare scalar for a +// single int/class, a flow sequence otherwise. +func parseExpect(input string) (*yaml.Node, error) { + toks := strings.Fields(strings.ReplaceAll(input, ",", " ")) + if len(toks) == 0 { + return nil, fmt.Errorf("expect_status needs at least one status (200, 2xx, 200,301)") + } + items := make([]*yaml.Node, 0, len(toks)) + for _, t := range toks { + if n, err := strconv.Atoi(t); err == nil { + items = append(items, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(n)}) + } else { + items = append(items, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: t}) + } + } + seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: items} + var spec config.StatusSpec + if err := spec.UnmarshalYAML(seq); err != nil { + return nil, err + } + if len(items) == 1 { + return items[0], nil + } + seq.Style = yaml.FlowStyle + return seq, nil +} + +// ---- render + gate + write ---- + +// renderInitDoc builds the YAML document: only keys init covers, with +// section comments. Everything else stays loader-defaulted. +func renderInitDoc(doc *initDoc) *yaml.Node { + str := func(v string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v} + } + num := func(n int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(n)} + } + pair := func(k string, v *yaml.Node) []*yaml.Node { + return []*yaml.Node{str(k), v} + } + mapping := func(pairs ...[]*yaml.Node) *yaml.Node { + m := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for _, p := range pairs { + m.Content = append(m.Content, p...) + } + return m + } + + root := mapping() + content := &root.Content + + server := mapping( + pair("listen", str(doc.listen)), + ) + server.HeadComment = "epmon API listen address." + db := mapping( + pair("driver", str("sqlite")), + pair("dsn", str(doc.dsn)), + pair("retention_days", num(doc.retention)), + ) + db.HeadComment = "Storage: sqlite file plus history retention." + probes := mapping( + pair("default_interval", str(doc.defInterval)), + pair("default_timeout", str(doc.defTimeout)), + pair("failure_threshold", num(doc.threshold)), + ) + probes.HeadComment = "Fleet probe defaults; services inherit unless overridden." + + svcs := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, s := range doc.services { + expect, err := parseExpect(orDefault(s.expect, "200")) + if err != nil { + expect = str("200") // validated earlier; cannot happen + } + fields := []*yaml.Node{str("id"), str(s.id)} + if s.name != "" && s.name != s.id { + fields = append(fields, str("name"), str(s.name)) + } + fields = append(fields, str("url"), str(s.url)) + if strings.TrimSpace(s.interval) != "" { + fields = append(fields, str("interval"), str(s.interval)) + } + if strings.TrimSpace(s.timeout) != "" { + fields = append(fields, str("timeout"), str(s.timeout)) + } + fields = append(fields, str("expect_status"), expect) + if s.body != "" { + fields = append(fields, str("body_contains"), str(s.body)) + } + if len(s.headers) > 0 { + hm := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for _, h := range s.headers { + hm.Content = append(hm.Content, str(h[0]), str(h[1])) + } + fields = append(fields, str("headers"), hm) + } + svc := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: fields} + svcs.Content = append(svcs.Content, svc) + } + + for _, kv := range [][]*yaml.Node{ + pair("server", server), + pair("database", db), + pair("probes", probes), + {str("services"), svcs}, + } { + *content = append(*content, kv...) + } + return &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}} +} + +// validateInitDoc marshals the rendered document and runs it through +// config.Parse — the exact loader path `run` uses. ${VAR} references the +// operator typed are stubbed during the check (and only the check) so +// substitution doesn't fail on values that will exist at runtime. +func validateInitDoc(doc *initDoc, node *yaml.Node) error { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(node); err != nil { + return err + } + if err := enc.Close(); err != nil { + return err + } + restore := stubMissingEnv(stubEnvNames(buf.Bytes())) + defer restore() + if _, err := config.Parse(doc.output, buf.Bytes()); err != nil { + return err + } + return nil +} + +var envRefRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}`) + +func stubEnvNames(raw []byte) []string { + var missing []string + for _, m := range envRefRe.FindAllSubmatch(raw, -1) { + name := string(m[1]) + if _, ok := os.LookupEnv(name); !ok { + missing = append(missing, name) + } + } + return missing +} + +func stubMissingEnv(names []string) func() { + prev := make(map[string]*string, len(names)) + for _, n := range names { + if v, ok := os.LookupEnv(n); ok { + p := v + prev[n] = &p + } else { + prev[n] = nil + } + _ = os.Setenv(n, "epmon-init-placeholder") + } + return func() { + for n, p := range prev { + if p == nil { + _ = os.Unsetenv(n) + } else { + _ = os.Setenv(n, *p) + } + } + } +} + +// writeInitDoc validates the rendered document, then writes it atomically +// (temp + rename in the destination directory). Existing files need +// --force; nothing is written on any failure. +func writeInitDoc(doc *initDoc, force bool, stdout io.Writer) error { + if _, err := os.Stat(doc.output); err == nil && !force { + return fmt.Errorf("%s exists (use --force to overwrite)", doc.output) + } + node := renderInitDoc(doc) + if err := validateInitDoc(doc, node); err != nil { + return fmt.Errorf("generated config is invalid: %v", err) + } + var buf bytes.Buffer + buf.WriteString("# Generated by `epmon init` — edit freely, then validate:\n") + buf.WriteString("# epmon validate --config " + doc.output + "\n") + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(node); err != nil { + return err + } + if err := enc.Close(); err != nil { + return err + } + dir := filepath.Dir(doc.output) + tmp, err := os.CreateTemp(dir, ".epmon-init-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(buf.Bytes()); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o644); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, doc.output); err != nil { + return err + } + fmt.Fprintf(stdout, "wrote %s (%d service(s)); verify with: epmon validate --config %s\n", + doc.output, len(doc.services), doc.output) + return nil +} diff --git a/cmd/epmon/init_test.go b/cmd/epmon/init_test.go new file mode 100644 index 0000000..31b5c89 --- /dev/null +++ b/cmd/epmon/init_test.go @@ -0,0 +1,265 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/epmon-dev/epmon/internal/config" + "gopkg.in/yaml.v3" +) + +func TestSecretRef(t *testing.T) { + for in, want := range map[string]string{ + "$TOKEN": "${TOKEN}", + "Bearer $TOKEN": "Bearer ${TOKEN}", + "${TOKEN}": "${TOKEN}", + "${T:-fallback}": "${T:-fallback}", + "$$": "$$", + "costs $$5": "costs $$5", + "plain": "plain", + "Bearer ${T} $U": "Bearer ${T} ${U}", + "trailing$": "trailing$", + "$9bad $GOOD": "$9bad ${GOOD}", + } { + if got := secretRef(in); got != want { + t.Errorf("secretRef(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseExpect(t *testing.T) { + single, err := parseExpect("200") + if err != nil { + t.Fatalf("parseExpect(200): %v", err) + } + if single.Tag != "!!int" || single.Value != "200" { + t.Errorf("single int emits %s %q, want scalar 200", single.Tag, single.Value) + } + class, err := parseExpect("2xx") + if err != nil { + t.Fatalf("parseExpect(2xx): %v", err) + } + if class.Tag != "!!str" || class.Value != "2xx" { + t.Errorf("single class emits %s %q, want scalar 2xx", class.Tag, class.Value) + } + multi, err := parseExpect("200, 301") + if err != nil { + t.Fatalf("parseExpect(200, 301): %v", err) + } + if multi.Kind != yaml.SequenceNode { + t.Errorf("multi emits kind %v, want sequence", multi.Kind) + } + for _, bad := range []string{"", "bogus", "99", "200, bogus", `"200"`} { + if _, err := parseExpect(bad); err == nil { + t.Errorf("parseExpect(%q) = nil error, want failure", bad) + } + } +} + +func TestInitFromFlagsRejects(t *testing.T) { + if _, err := initFromFlags("o.yaml", ":8080", "e.db", 90, "60s", "10s", 1, nil); err == nil { + t.Error("zero services accepted, want failure") + } + for _, raw := range []string{"nourl", "=https://x.example.com", "id=", "api=https://a.example.com;bogus=1"} { + if _, err := initFromFlags("o.yaml", ":8080", "e.db", 90, "60s", "10s", 1, serviceFlag{raw}); err == nil { + t.Errorf("service %q accepted, want failure", raw) + } + } +} + +// TestInitNonInteractiveGolden drives flags through rendering, asserts the +// exact file, and proves the gate by loading it with the real loader. +func TestInitNonInteractiveGolden(t *testing.T) { + out := filepath.Join(t.TempDir(), "epmon.yaml") + doc, err := initFromFlags(out, ":8080", "epmon.db", 90, "60s", "10s", 1, serviceFlag{ + "api=https://api.example.com/healthz;interval=30s;expect=200,301", + "web=https://example.com", + }) + if err != nil { + t.Fatalf("initFromFlags: %v", err) + } + if err := writeInitDoc(doc, false, io.Discard); err != nil { + t.Fatalf("writeInitDoc: %v", err) + } + raw, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read output: %v", err) + } + want := "# Generated by `epmon init` — edit freely, then validate:\n" + + "# epmon validate --config " + out + "\n" + + "server:\n" + + " # epmon API listen address.\n" + + " listen: :8080\n" + + "database:\n" + + " # Storage: sqlite file plus history retention.\n" + + " driver: sqlite\n" + + " dsn: epmon.db\n" + + " retention_days: 90\n" + + "probes:\n" + + " # Fleet probe defaults; services inherit unless overridden.\n" + + " default_interval: 60s\n" + + " default_timeout: 10s\n" + + " failure_threshold: 1\n" + + "services:\n" + + " - id: api\n" + + " url: https://api.example.com/healthz\n" + + " interval: 30s\n" + + " expect_status: [200, 301]\n" + + " - id: web\n" + + " url: https://example.com\n" + + " expect_status: 200\n" + if string(raw) != want { + t.Errorf("golden mismatch:\n got:\n%s\nwant:\n%s", raw, want) + } + if _, err := config.Load(out); err != nil { + t.Errorf("generated file fails the real loader: %v", err) + } +} + +func TestInitRefusesOverwrite(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "epmon.yaml") + if err := os.WriteFile(out, []byte("existing: true\n"), 0o644); err != nil { + t.Fatal(err) + } + doc, err := initFromFlags(out, ":8080", "e.db", 90, "60s", "10s", 1, + serviceFlag{"a=https://a.example.com"}) + if err != nil { + t.Fatal(err) + } + if err := writeInitDoc(doc, false, io.Discard); err == nil { + t.Fatal("overwrite without --force accepted") + } + raw, _ := os.ReadFile(out) + if string(raw) != "existing: true\n" { + t.Error("existing file touched despite refusal") + } + if err := writeInitDoc(doc, true, io.Discard); err != nil { + t.Fatalf("--force write: %v", err) + } + if _, err := config.Load(out); err != nil { + t.Errorf("forced file fails the loader: %v", err) + } +} + +// TestInitGateLeavesNothingBehind feeds an un-runnable document (bad URL +// passes the fast checks but the loader rejects it) and asserts neither +// the destination nor temp files appear. +func TestInitGateLeavesNothingBehind(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "epmon.yaml") + doc := &initDoc{ + output: out, listen: ":8080", dsn: "e.db", retention: 90, + defInterval: "60s", defTimeout: "10s", threshold: 1, + services: []initService{{id: "a", url: "https://a.example.com", expect: "200"}}, + } + doc.services[0].interval = "1s" // parses, but loader demands >= 5s + if err := writeInitDoc(doc, false, io.Discard); err == nil { + t.Fatal("invalid document written") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("leftovers in %s: %v", dir, names) + } +} + +const interactiveInput = ` +db.sqlite + + + + +api +API +https://api.example.com/healthz +30s + +2xx + +Authorization: Bearer $TOKEN + +web + +https://example.com + + + + + + +` + +func TestInitInteractiveGolden(t *testing.T) { + out := filepath.Join(t.TempDir(), "epmon.yaml") + doc, err := initInteractive(strings.NewReader(interactiveInput), io.Discard, out) + if err != nil { + t.Fatalf("initInteractive: %v", err) + } + if len(doc.services) != 2 || doc.services[0].id != "api" || doc.dsn != "db.sqlite" { + t.Fatalf("unexpected doc: %+v", doc) + } + if got := doc.services[0].headers[0]; got != [2]string{"Authorization", "Bearer ${TOKEN}"} { + t.Errorf("header = %v, want secret reference", got) + } + if err := writeInitDoc(doc, false, io.Discard); err != nil { + t.Fatalf("writeInitDoc: %v", err) + } + // The stubbed gate must hold with the real variable present too. + t.Setenv("TOKEN", "secret") + if _, err := config.Load(out); err != nil { + t.Errorf("generated file fails the real loader: %v", err) + } +} + +func TestInitInteractiveAbort(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "epmon.yaml") + // EOF mid-service: no file may appear. + if _, err := initInteractive(strings.NewReader("\n\ndb.sqlite\n\n\n\n\napi\n"), io.Discard, out); err == nil { + t.Fatal("EOF accepted, want abort") + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Error("file written despite abort") + } + // Empty id up front re-prompts instead of producing zero services. + doc, err := initInteractive(strings.NewReader("\ndb.sqlite\n\n\n\n\n\nweb\n\nhttps://example.com\n\n\n\n\n\n\n"), io.Discard, out) + if err != nil { + t.Fatalf("empty-first-id run: %v", err) + } + if len(doc.services) != 1 { + t.Errorf("got %d services, want 1", len(doc.services)) + } +} + +func TestInitCommandUsage(t *testing.T) { + if code := initCommand([]string{"positional"}, strings.NewReader(""), io.Discard, io.Discard); code != exitUsage { + t.Errorf("positional args = %d, want %d", code, exitUsage) + } + if code := initCommand([]string{"--service", "a=https://a.example.com"}, strings.NewReader(""), io.Discard, io.Discard); code != exitUsage { + t.Errorf("--service without --non-interactive = %d, want %d", code, exitUsage) + } +} + +func TestInitCommandNonInteractive(t *testing.T) { + out := filepath.Join(t.TempDir(), "epmon.yaml") + code := initCommand([]string{ + "--non-interactive", "--output", out, + "--service", "a=https://a.example.com", + }, strings.NewReader(""), io.Discard, io.Discard) + if code != exitOK { + t.Fatalf("initCommand = %d, want %d", code, exitOK) + } + if _, err := config.Load(out); err != nil { + t.Errorf("generated file fails the real loader: %v", err) + } +} diff --git a/cmd/epmon/main.go b/cmd/epmon/main.go index 743537b..3e65022 100644 --- a/cmd/epmon/main.go +++ b/cmd/epmon/main.go @@ -1,7 +1,7 @@ // Command epmon monitors HTTP endpoints from a YAML/JSON catalogue, // stores every probe in SQLite, and serves the results as JSON. // -// Usage: epmon [run|validate|healthcheck|version] [-config config.yaml] +// Usage: epmon [run|validate|healthcheck|version|init] [-config config.yaml] package main import ( @@ -51,8 +51,10 @@ func execute(ctx context.Context, args []string, stdout, stderr io.Writer) int { return healthcheckEndpoint(args, stdout, stderr) case "version": return printVersion(stdout, stderr) + case "init": + return initCommand(args[1:], stdinReader(), stdout, stderr) default: - fmt.Fprintf(stderr, "epmon: unknown command %q (want run|validate|healthcheck|version)\n", args[0]) + fmt.Fprintf(stderr, "epmon: unknown command %q (want run|validate|healthcheck|version|init)\n", args[0]) return exitUsage } } @@ -257,6 +259,10 @@ var ( date = "unknown" ) +// stdinReader is a seam so init's prompt loop stays testable; production +// passes the real stdin through. +func stdinReader() io.Reader { return os.Stdin } + func main() { os.Exit(execute(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/internal/api/api.go b/internal/api/api.go index 835a6cf..b3c0d44 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -31,6 +31,7 @@ import ( "strings" "time" + "github.com/epmon-dev/epmon/internal/api/webui" "github.com/epmon-dev/epmon/internal/config" "github.com/epmon-dev/epmon/internal/store" "gopkg.in/yaml.v3" @@ -84,7 +85,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET "+prefix+"/openapi.yaml", s.serveSpecYAML) mux.HandleFunc("GET "+prefix+"/openapi.json", s.serveSpecJSON) mux.HandleFunc("GET /docs", s.serveDocs) - mux.HandleFunc("/", notFound) + mux.HandleFunc("/", s.root) limiter := newRateLimiter(s.cfg.Server.RateLimitRPM, s.cfg.Server.RateLimitBurst) var h http.Handler = mux @@ -124,6 +125,23 @@ func notFound(w http.ResponseWriter, _ *http.Request) { writeErr(w, http.StatusNotFound, "not_found", "unknown endpoint") } +// root serves the embedded status UI (spec §9) when enabled; otherwise +// the historical JSON 404. API routes always win — mux longest-match +// routes them before this catch-all. Unknown /api/* paths stay JSON: +// API clients must never receive the SPA shell. +func (s *Server) root(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + if p == "/api" || strings.HasPrefix(p, "/api/") { + notFound(w, r) + return + } + if s.cfg.StatusPageEnabled() { + webui.Handler().ServeHTTP(w, r) + return + } + notFound(w, r) +} + func (s *Server) serveSpecYAML(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/yaml") w.WriteHeader(http.StatusOK) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 0002627..a0bf6e3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -107,7 +107,9 @@ func TestRoutingErrorsAreJSON(t *testing.T) { srv, _ := testServer(t) h := srv.Handler() - for _, path := range []string{"/nope", "/api/v1/nope", "/api/v2/status", "/api/status"} { + // Non-API unknowns ("/nope") serve the SPA shell when the status + // page is enabled; /api/* unknowns stay JSON. Both are pinned here. + for _, path := range []string{"/api/v1/nope", "/api/v2/status", "/api/status", "/api"} { code, body, _ := do(t, h, "GET", path, "") if code != 404 { t.Errorf("GET %s = %d, want 404", path, code) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 29cc150..48da3eb 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -77,7 +77,7 @@ func TestAPIContractFromSpec(t *testing.T) { {"GET", "/api/v1/openapi.yaml", "/api/v1/openapi.yaml", ""}, {"GET", "/api/v1/openapi.json", "/api/v1/openapi.json", ""}, {"GET", "/docs", "/docs", ""}, - {"GET", "/nope", "/nope", ""}, + {"GET", "/api/v1/nope", "/nope", ""}, } covered := map[string]bool{} diff --git a/internal/api/root_test.go b/internal/api/root_test.go new file mode 100644 index 0000000..ec5b6aa --- /dev/null +++ b/internal/api/root_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/epmon-dev/epmon/internal/config" +) + +// TestRootGating pins the spec §9 contract: the SPA shell when enabled, +// the historical JSON 404 when disabled, and JSON 404 for /api/* +// unknowns in both modes (API clients must never get HTML). +func TestRootGating(t *testing.T) { + srv, _ := testServer(t) // StatusPage.Enabled nil → default true + enabled := srv.Handler() + + falsy := false + offCfg := &config.Config{ + Server: config.Server{ + MaxBodyBytes: 1 << 20, + StatusPage: config.StatusPageConfig{Enabled: &falsy}, + }, + Services: []config.Service{ + {ID: "web", Name: "Web", URL: "https://example.com"}, + }, + } + off := New(offCfg, openTestStore(t), time.Now).Handler() + + serve := func(h http.Handler, path string) (int, string, http.Header) { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code, rec.Body.String(), rec.Header() + } + + if code, _, header := serve(enabled, "/"); code != 302 || header.Get("Location") != "/local" { + t.Errorf("enabled / = %d, want redirect to /local", code) + } + if code, body, _ := serve(enabled, "/local"); code != 200 || !strings.Contains(body, `
`) { + t.Errorf("enabled /local = %d, want the app shell", code) + } + if code, body, _ := serve(enabled, "/p/acme"); code != 200 || !strings.Contains(body, `
`) { + t.Errorf("enabled /p/acme = %d, want SPA fallback", code) + } + if code, body, _ := serve(enabled, "/api/v1/nope"); code != 404 || !strings.Contains(body, "not_found") { + t.Errorf("enabled /api/v1/nope = %d %q, want JSON 404", code, body) + } + if code, body, _ := serve(off, "/"); code != 404 || !strings.Contains(body, "not_found") { + t.Errorf("disabled / = %d %q, want JSON 404", code, body) + } + if code, body, _ := serve(off, "/api/v1/nope"); code != 404 || !strings.Contains(body, "not_found") { + t.Errorf("disabled /api/v1/nope = %d %q, want JSON 404", code, body) + } +} diff --git a/internal/api/webui/assets/index-5U1zY8sQ.js b/internal/api/webui/assets/index-5U1zY8sQ.js new file mode 100644 index 0000000..069d450 --- /dev/null +++ b/internal/api/webui/assets/index-5U1zY8sQ.js @@ -0,0 +1,2 @@ +import{n as e}from"./rolldown-runtime-CbXtAM7H.js";import{n as t,r as n,t as r}from"./react-Dy-GIXkn.js";import{a as i,i as a,n as o,o as s,r as c,s as l,t as u}from"./router-BUCJvZia.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var d=e(n(),1),f=t(),p={slug:`acme-inc`,displayName:`Acme Inc`,tier:`pro`,tagline:`Widgets, shipped reliably.`,logoUrl:null,domains:[`status.acme-inc.com`],customCss:``,hideBranding:!1,services:[`web-app`,`api-gateway`,`cdn-edge`]},m={slug:`globex`,displayName:`Globex`,tier:`team`,tagline:`Global logistics, visibly reliable.`,logoUrl:null,domains:[`status.globex.com`,`status-eu.globex.com`],customCss:`.tenant-accent { color: #1d4ed8; } +.tenant-hero { border-left: 4px solid #1d4ed8; padding-left: 1rem; }`,hideBranding:!0,services:[`web-app`,`api-gateway`,`cdn-edge`]},h={slug:`hobby-demo`,displayName:`Hobby Demo`,tier:`hobby`,tagline:`A community project monitored with epmon.`,logoUrl:null,domains:[],customCss:``,hideBranding:!1,services:[`web-app`,`api-gateway`]},g={hobby:`Hobby`,pro:`Pro`,team:`Team`},_=[h,p,m];function v(){return _}function y(e){return _.find(t=>t.slug===e)}function b(e){let t=e.toLowerCase().split(`:`)[0];return _.find(e=>e.domains.some(e=>e.toLowerCase()===t))}function ee(e){return`/p/${e}`}function te(e){return`/s/${e.domains[0]??e.slug}`}function ne(e){return e.tier===`team`?e.customCss:``}function re(e){return e.tier===`team`&&e.hideBranding}function ie(e){return{id:e.id,name:e.name,url:e.url,up:e.up,latencyMs:e.latency_ms,statusCode:e.status_code,checkedAt:e.checked_at,error:e.error}}function ae(e){return{id:e.id,serviceId:e.service_id,title:e.title,state:e.state,severity:e.severity,createdAt:e.created_at,updatedAt:e.updated_at,updates:(e.updates??[]).map(e=>({ts:e.ts,text:e.text}))}}function oe(e){return e.up===!0?`operational`:e.up===!1?`outage`:`unknown`}function se(){return``}async function x(e,t){let n=await fetch(`${se()}${e}`,{headers:{Accept:`application/json`},cache:`no-store`,signal:t});if(!n.ok)throw Error(`GET ${e} -> ${n.status}`);return await n.json()}function ce(e){if(!e||typeof e.overall!=`string`||!Array.isArray(e.services))throw Error(`bad /api/v1/status shape`);return{checkedAt:e.checked_at,overall:e.overall,services:e.services.map(ie)}}async function S(e){return ce(await x(`/api/v1/status`,e))}async function C(e,t=90,n){let r=await x(`/api/v1/services/${encodeURIComponent(e)}/history?days=${t}`,n);if(!r||!Array.isArray(r.history))throw Error(`bad history shape`);return{serviceId:r.service_id,days:r.days,uptimePct:r.uptime_pct,buckets:r.history.map(e=>({date:e.date,up:e.up,checks:e.checks}))}}async function w(e={},t){let n=new URLSearchParams;e.state&&n.set(`state`,e.state),e.serviceId&&n.set(`service`,e.serviceId),n.set(`limit`,`100`);let r=await x(`/api/v1/incidents${`?${n.toString()}`}`,t);if(!r||!Array.isArray(r.incidents))throw Error(`bad incidents shape`);let i=r.incidents.map(ae);return e.open!==void 0&&(i=i.filter(t=>e.open?t.state!==`resolved`:t.state===`resolved`)),{items:i,total:r.total}}function T(e){if(e===null)return`No data yet`;let t=new Date(e*1e3);return Number.isNaN(t.getTime())?`—`:t.toLocaleString()}function E(e,t){if(e===null)return`never`;let n=Math.max(0,Math.floor(t/1e3)-e);if(n<5)return`just now`;if(n<60)return`${n}s ago`;let r=Math.floor(n/60);if(r<60)return`${r}m ago`;let i=Math.floor(r/60);if(i<24)return`${i}h ago`;let a=Math.floor(i/24);return a===1?`yesterday`:`${a}d ago`}function D(e=1e4){let[t,n]=(0,d.useState)(()=>Date.now());return(0,d.useEffect)(()=>{let t=window.setInterval(()=>n(Date.now()),e);return()=>window.clearInterval(t)},[e]),t}function O(e){let t=new URLSearchParams(e).get(`preview`);return t===`operational`||t===`degraded`||t===`partial_outage`||t===`major_outage`||t===`unknown`?t:t===`outage`?`partial_outage`:null}function k(e,t=15e3){let[n,r]=(0,d.useState)(null),[i,a]=(0,d.useState)(null),[o,s]=(0,d.useState)(null),c=(0,d.useRef)(e);c.current=e;let l=(0,d.useCallback)(async e=>{try{let t=await c.current(e);if(e.aborted)return;r(t),a(null),s(new Date)}catch(t){if(e.aborted)return;a(t instanceof Error?t.message:`request failed`)}},[]);return(0,d.useEffect)(()=>{let e=new AbortController;l(e.signal);let n=window.setInterval(()=>{let e=new AbortController;l(e.signal)},t);return()=>{e.abort(),window.clearInterval(n)}},[l,t]),{data:n,error:i,refreshedAt:o}}var A={operational:`All systems operational`,degraded:`Degraded performance`,partial_outage:`Partial outage`,major_outage:`Major outage`,unknown:`Status unknown`},le={operational:`Operational`,outage:`Outage`,unknown:`Unknown`},j=r(),ue=[`investigating`,`monitoring`,`resolved`],de={critical:`bg-red-500`,major:`bg-orange-500`,minor:`bg-amber-500`},M=`rounded-lg px-2.5 py-1.5 text-sm font-medium`;function N({incidents:e,serviceIds:t,basePath:n}){let[r,i]=l(),a=D(),s=r.get(`service_id`)??``,c=r.get(`state`)??``,u=r.get(`open`)===`true`,d=r.get(`incident`),f=e.filter(e=>{if(s===`__platform`){if(e.serviceId!==void 0)return!1}else if(s&&e.serviceId!==s)return!1;return!(c&&e.state!==c||u&&e.state===`resolved`)}),p=f.find(e=>String(e.id)===d)??null,m=(e,t)=>{let n=new URLSearchParams(r);t===null||t===``?n.delete(e):n.set(e,t),i(n,{replace:!0})};return(0,j.jsxs)(`section`,{id:`incidents`,"aria-labelledby":`incidents-h`,className:`scroll-mt-6 rounded-2xl p-5 sm:p-6`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`},children:[(0,j.jsxs)(`div`,{className:`flex flex-wrap items-end justify-between gap-3`,children:[(0,j.jsx)(`h2`,{id:`incidents-h`,className:`text-xl font-bold tracking-tight`,children:`Incidents`}),(0,j.jsxs)(`span`,{className:`tabular font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[f.length,` shown`]})]}),(0,j.jsxs)(`form`,{className:`mt-4 flex flex-wrap items-end gap-2.5 text-sm`,"aria-label":`Filter incidents`,onSubmit:e=>e.preventDefault(),children:[(0,j.jsxs)(`label`,{className:`flex flex-col gap-1.5 font-medium`,children:[(0,j.jsx)(`span`,{className:`text-xs`,style:{color:`var(--muted-fg)`},children:`Service`}),(0,j.jsxs)(`select`,{value:s,onChange:e=>m(`service_id`,e.target.value||null),className:M,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`,color:`inherit`},children:[(0,j.jsx)(`option`,{value:``,children:`All services`}),t.map(e=>(0,j.jsx)(`option`,{value:e,children:e},e)),(0,j.jsx)(`option`,{value:`__platform`,children:`Platform-wide`})]})]}),(0,j.jsxs)(`label`,{className:`flex flex-col gap-1.5 font-medium`,children:[(0,j.jsx)(`span`,{className:`text-xs`,style:{color:`var(--muted-fg)`},children:`State`}),(0,j.jsxs)(`select`,{value:c,onChange:e=>m(`state`,e.target.value||null),className:M,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`,color:`inherit`},children:[(0,j.jsx)(`option`,{value:``,children:`Any state`}),ue.map(e=>(0,j.jsx)(`option`,{value:e,children:e},e))]})]}),(0,j.jsxs)(`label`,{className:`flex cursor-pointer items-center gap-2 pb-2 font-medium`,children:[(0,j.jsx)(`input`,{type:`checkbox`,checked:u,onChange:e=>m(`open`,e.target.checked?`true`:null),className:`h-4 w-4 accent-emerald-600`}),`Open only`]})]}),f.length===0?(0,j.jsx)(`p`,{className:`mt-4 rounded-xl p-5 text-center text-sm`,style:{background:`var(--pill-bg)`,color:`var(--muted-fg)`},children:`Nothing here. When something breaks, the entry lands in this feed with its full update thread.`}):(0,j.jsx)(`ul`,{className:`mt-4 space-y-2.5`,children:f.map(e=>(0,j.jsx)(`li`,{children:(0,j.jsxs)(o,{to:`${n}?incident=${e.id}`,className:`flex items-start gap-3 rounded-xl p-3.5 transition hover:ring-1`,style:{background:`var(--pill-bg)`},children:[(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full ${de[e.severity]}`}),(0,j.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,j.jsxs)(`span`,{className:`block truncate font-semibold`,children:[`#`,e.id,` — `,e.title]}),(0,j.jsxs)(`span`,{className:`tabular mt-0.5 block font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[e.state,` · `,e.severity,` · `,e.serviceId??`platform-wide`,` · `,E(e.createdAt,a)]})]})]})},e.id))}),p&&(0,j.jsxs)(`article`,{"aria-label":`Incident ${p.id}`,className:`mt-4 rounded-xl p-4 sm:p-5`,style:{border:`1px solid var(--card-border)`},children:[(0,j.jsxs)(`h3`,{className:`text-lg font-bold tracking-tight`,children:[`#`,p.id,` — `,p.title]}),(0,j.jsxs)(`p`,{className:`tabular mt-1 font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[p.state,` · `,p.severity,` · opened `,T(p.createdAt),p.state===`resolved`&&(0,j.jsxs)(j.Fragment,{children:[` · resolved `,T(p.updatedAt)]})]}),(0,j.jsx)(`ol`,{className:`mt-3 space-y-2.5`,children:p.updates.map((e,t)=>(0,j.jsxs)(`li`,{className:`border-l-2 pl-3 text-sm leading-relaxed`,style:{borderColor:`var(--card-border)`},children:[(0,j.jsx)(`span`,{className:`tabular mr-2 font-mono text-xs`,style:{color:`var(--muted-fg)`},children:T(e.ts)}),e.text]},`${e.ts}-${t}`))})]})]})}var P={operational:`bg-emerald-500`,degraded:`bg-amber-500`,partial_outage:`bg-orange-500`,major_outage:`bg-red-500`,unknown:`bg-zinc-500`},F={operational:`rgba(16, 185, 129, 0.16)`,degraded:`rgba(245, 158, 11, 0.16)`,partial_outage:`rgba(249, 115, 22, 0.18)`,major_outage:`rgba(239, 68, 68, 0.20)`,unknown:`rgba(113, 113, 122, 0.16)`};function I({overall:e,checkedAt:t,live:n,now:r}){return(0,j.jsxs)(`section`,{role:`status`,"aria-live":`polite`,"aria-label":`Overall status: ${A[e]}`,className:`relative overflow-hidden rounded-3xl px-6 py-10 text-center sm:py-14`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`},children:[(0,j.jsx)(`div`,{"aria-hidden":`true`,className:`pointer-events-none absolute inset-x-0 -top-24 mx-auto h-64 max-w-xl rounded-full blur-3xl`,style:{background:F[e]}}),(0,j.jsxs)(`div`,{className:`relative`,children:[(0,j.jsxs)(`p`,{className:`inline-flex items-center gap-2 rounded-full px-3.5 py-1.5 font-mono text-xs`,style:{border:`1px solid var(--card-border)`,background:`var(--pill-bg)`},children:[(0,j.jsxs)(`span`,{className:`relative flex h-2 w-2`,children:[(0,j.jsx)(`span`,{className:`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${P[e]}`}),(0,j.jsx)(`span`,{className:`relative inline-flex h-2 w-2 rounded-full ${P[e]}`})]}),n?`LIVE`:`DEMO`,(0,j.jsxs)(`span`,{style:{color:`var(--muted-fg)`},children:[`· updated `,E(t,r)]})]}),(0,j.jsx)(`h2`,{className:`mx-auto mt-5 max-w-2xl text-4xl font-extrabold leading-[1.05] tracking-tight sm:text-5xl`,children:A[e]}),(0,j.jsx)(`p`,{className:`mx-auto mt-3 max-w-md text-sm leading-relaxed`,style:{color:`var(--muted-fg)`},children:`Gaps in monitoring show as gaps, never as green.`})]})]})}function L(e){return e.up===null?`${e.date}: no data — the monitor was down, not necessarily the service`:`${e.date}: ${e.up?`up`:`down`}, ${e.checks.toLocaleString()} checks`}function R(e){return e.up===null?`${e.date} · no data`:`${e.date} · ${e.up?`up`:`down`} · ${e.checks.toLocaleString()} checks`}function z({buckets:e,serviceName:t,hovered:n,onHover:r}){return(0,j.jsxs)(`div`,{onMouseLeave:()=>r(null),children:[(0,j.jsx)(`p`,{className:`tabular h-5 truncate font-mono text-xs`,style:{color:`var(--muted-fg)`},"aria-live":`polite`,children:n!==null&&e[n]?R(e[n]):`Hover any day for the exact numbers`}),(0,j.jsx)(`div`,{className:`mt-1.5 flex h-10 items-stretch gap-[2px]`,role:`img`,"aria-label":`History for ${t}`,children:e.map((e,t)=>(0,j.jsx)(`div`,{title:L(e),onMouseEnter:()=>r(t),onFocus:()=>r(t),tabIndex:0,className:`daybar min-w-0 flex-1 cursor-crosshair rounded-[3px] ${e.up===null?`bar--nodata`:e.up?`bar--up`:`bar--outage`}`},e.date))}),(0,j.jsxs)(`ul`,{className:`mt-2.5 flex flex-wrap gap-x-4 gap-y-1 text-xs`,style:{color:`var(--muted-fg)`},"aria-label":`Legend`,children:[(0,j.jsxs)(`li`,{className:`inline-flex items-center gap-1.5`,children:[(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`bar--up inline-block h-2 w-2 rounded-sm`}),` Up`]}),(0,j.jsxs)(`li`,{className:`inline-flex items-center gap-1.5`,children:[(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`bar--outage inline-block h-2 w-2 rounded-sm`}),` Down`]}),(0,j.jsxs)(`li`,{className:`inline-flex items-center gap-1.5`,children:[(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`bar--nodata inline-block h-2 w-2 rounded-sm`}),` No data`]})]})]})}var B={operational:`bg-emerald-500`,outage:`bg-red-500`,unknown:`bg-zinc-500`};function V({service:e,history:t,activeIncidentIds:n,now:r}){let[i,a]=(0,d.useState)(null),o=oe(e),s=t?.buckets??[],c=t?.uptimePct,l=s.filter(e=>e.up!==null).length;return(0,j.jsxs)(`article`,{"aria-label":e.name,className:`rounded-2xl p-5 transition sm:p-6`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`},children:[(0,j.jsxs)(`div`,{className:`flex flex-wrap items-center justify-between gap-3`,children:[(0,j.jsxs)(`div`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,j.jsxs)(`span`,{className:`relative flex h-2.5 w-2.5 shrink-0`,children:[(0,j.jsx)(`span`,{className:`absolute inline-flex h-full w-full animate-ping rounded-full opacity-60 ${B[o]}`}),(0,j.jsx)(`span`,{className:`relative inline-flex h-2.5 w-2.5 rounded-full ${B[o]}`})]}),(0,j.jsx)(`h3`,{className:`truncate text-lg font-bold tracking-tight`,children:e.name})]}),(0,j.jsx)(`span`,{className:`shrink-0 rounded-full px-3 py-1 text-xs font-semibold`,style:{border:`1px solid var(--card-border)`,background:`var(--pill-bg)`},children:le[o]})]}),(0,j.jsxs)(`p`,{className:`tabular mt-2.5 font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[e.up===!0&&e.latencyMs!==null&&(0,j.jsxs)(j.Fragment,{children:[e.latencyMs,` ms · HTTP `,e.statusCode??`?`,` · checked `,E(e.checkedAt,r)]}),e.up===!1&&(0,j.jsxs)(j.Fragment,{children:[`DOWN`,e.error?` (${e.error})`:``,` · checked `,E(e.checkedAt,r)]}),e.up===null&&`No checks yet — unknown until the first one completes`]}),n.length>0&&(0,j.jsxs)(`p`,{className:`mt-2.5 rounded-xl px-3 py-2 text-sm`,style:{background:`var(--warn-bg)`,color:`var(--warn-fg)`},children:[`Active incident:`,` `,(0,j.jsxs)(`a`,{className:`font-semibold underline underline-offset-2`,href:`#incidents`,children:[`#`,n.join(`, #`)]})]}),(0,j.jsx)(`div`,{className:`mt-4`,children:t?(0,j.jsx)(z,{buckets:s,serviceName:e.name,hovered:i,onHover:a}):(0,j.jsx)(`p`,{className:`text-sm`,style:{color:`var(--muted-fg)`},children:`Loading history…`})}),(0,j.jsxs)(`div`,{className:`mt-3.5 flex flex-wrap items-baseline justify-between gap-2 border-t pt-3.5`,style:{borderColor:`var(--card-border)`},children:[(0,j.jsxs)(`span`,{className:`text-xs`,style:{color:`var(--muted-fg)`},children:[`Last 90 days · `,l,` with data`]}),(0,j.jsx)(`span`,{className:`tabular font-mono text-base font-bold`,children:c==null?`—`:`${c.toFixed(2)}%`})]})]})}var H=e=>`epmon:notify:${e}`;function fe(e){try{let t=window.localStorage.getItem(H(e));if(t)return JSON.parse(t)}catch{}return{enabled:!1,lastOverall:null,seenIds:[]}}function pe({slug:e,tenantName:t}){let[n,r]=(0,d.useState)(()=>typeof Notification>`u`?`unsupported`:`prompt`),i=(0,d.useRef)(fe(e));(0,d.useEffect)(()=>{if(typeof Notification>`u`){r(`unsupported`);return}Notification.permission===`denied`?(i.current.enabled=!1,r(`denied`)):i.current.enabled&&Notification.permission===`granted`?r(`on`):r(`off`)},[]);let a=(0,d.useCallback)(t=>{i.current=t;try{window.localStorage.setItem(H(e),JSON.stringify(t))}catch{}},[e]);(0,d.useEffect)(()=>{if(n!==`on`)return;let e=!1;async function r(){try{let[n,r]=await Promise.all([S(),w({open:!0})]);if(e)return;let o=i.current,s=[];o.lastOverall!==null&&o.lastOverall!==n.overall&&s.push(`${t}: status changed to ${n.overall.replaceAll(`_`,` `)}`);let c=r.items.filter(e=>!o.seenIds.includes(e.id));for(let e of c.slice(0,3))s.push(`#${e.id} ${e.title}`);a({enabled:!0,lastOverall:n.overall,seenIds:r.items.map(e=>e.id)});for(let e of s)try{new Notification(t,{body:e})}catch{}}catch{}}r();let o=window.setInterval(r,6e4);return()=>{e=!0,window.clearInterval(o)}},[n,a,t]);let o=(0,d.useCallback)(async()=>{if(typeof Notification>`u`)return;let e=await Notification.requestPermission();e===`granted`?(a({enabled:!0,lastOverall:null,seenIds:[]}),r(`on`)):e===`denied`&&(a({enabled:!1,lastOverall:null,seenIds:[]}),r(`denied`))},[a]),s=(0,d.useCallback)(()=>{a({enabled:!1,lastOverall:null,seenIds:[]}),r(`off`)},[a]);return n===`unsupported`?null:(0,j.jsxs)(`section`,{"aria-labelledby":`subscribe-h`,className:`rounded-2xl p-5 sm:p-6`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`},children:[(0,j.jsx)(`h2`,{id:`subscribe-h`,className:`text-xl font-bold tracking-tight`,children:`Stay informed`}),(0,j.jsx)(`p`,{className:`mt-1.5 text-sm leading-relaxed`,style:{color:`var(--muted-fg)`},children:n===`on`?`This browser notifies you when the status changes or a new incident appears. Keep the page open in a tab.`:n===`denied`?`Notifications are blocked for this site. Re-enable them in your browser’s site settings, then come back.`:`Get a notification in this browser when the status changes or a new incident appears. No account, nothing leaves your device.`}),(0,j.jsx)(`div`,{className:`mt-4`,children:n===`on`?(0,j.jsx)(`button`,{type:`button`,onClick:s,className:`rounded-full px-5 py-2.5 text-sm font-semibold transition`,style:{border:`1px solid var(--card-border)`},children:`Turn off notifications`}):n!==`denied`&&(0,j.jsx)(`button`,{type:`button`,onClick:o,className:`rounded-full bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-emerald-500`,children:`Notify me in this browser`})})]})}function me({tenant:e,customDomain:t}){let n=ne(e),r=re(e),i=e.domains[0];return(0,j.jsxs)(`header`,{children:[n&&(0,j.jsx)(`style`,{children:n}),(0,j.jsxs)(`div`,{className:`flex items-center gap-3.5`,children:[e.logoUrl?(0,j.jsx)(`img`,{src:e.logoUrl,alt:`${e.displayName} logo`,className:`h-11 w-11 rounded-xl object-cover`}):(0,j.jsx)(`span`,{"aria-hidden":`true`,className:`flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-700 text-xl font-bold text-white`,children:e.displayName.slice(0,1)}),(0,j.jsxs)(`div`,{className:`min-w-0`,children:[(0,j.jsxs)(`h1`,{className:`tenant-hero truncate text-2xl font-bold tracking-tight`,children:[e.displayName,` `,(0,j.jsx)(`span`,{className:`font-medium opacity-60`,children:`status`})]}),(0,j.jsx)(`p`,{className:`truncate text-sm`,style:{color:`var(--muted-fg)`},children:e.tagline})]})]}),t&&i&&(0,j.jsxs)(`p`,{className:`mt-3 inline-flex items-center gap-1.5 text-xs`,style:{color:`var(--muted-fg)`},children:[(0,j.jsxs)(`svg`,{"aria-hidden":`true`,width:`12`,height:`12`,viewBox:`0 0 16 16`,fill:`none`,children:[(0,j.jsx)(`rect`,{x:`3`,y:`7`,width:`10`,height:`7`,rx:`1.5`,stroke:`currentColor`,strokeWidth:`1.5`}),(0,j.jsx)(`path`,{d:`M5.5 7V5a2.5 2.5 0 0 1 5 0v2`,stroke:`currentColor`,strokeWidth:`1.5`})]}),i,` · secured with automatic TLS`]}),!r&&(0,j.jsx)(`p`,{className:`mt-3 text-xs`,style:{color:`var(--muted-fg)`},children:`Powered by epmon.dev`})]})}function he(e){let t=e>>>0;return()=>{t|=0,t=t+1831565813|0;let e=Math.imul(t^t>>>15,1|t);return e=e+Math.imul(e^e>>>7,61|e)^e,((e^e>>>14)>>>0)/4294967296}}function U(e){return e.toISOString().slice(0,10)}function ge(e){let t=2166136261;for(let n=0;n>>0}var W=Math.floor(Date.now()/1e3);function G(e){let t=new Date,n=new Date(Date.UTC(t.getUTCFullYear(),t.getUTCMonth(),t.getUTCDate()));return n.setUTCDate(n.getUTCDate()-e),n}var K=e=>Math.floor(e.getTime()/1e3);function q(e,t=90){let n=he(ge(e)),r=[],i=0,a=0;for(let o=t-1;o>=0;o--){let s=G(o),c=t-1-o;if(e===`cdn-edge`&&c<14){r.push({date:U(s),up:null,checks:0});continue}let l=n(),u=e===`api-gateway`&&o>=8&&o<=10,d=e===`cdn-edge`?720:2880,f;f=u?o!==9:!(l<.03);let p=f?d:Math.floor(d*(.9+n()*.08));i++,f&&a++,r.push({date:U(s),up:f,checks:p})}return{serviceId:e,days:t,uptimePct:i>0?Math.round(100*a/i*100)/100:null,buckets:r}}var J=W-42;function _e(){return[{id:`web-app`,name:`Web App`,url:`https://example.com`,up:!0,latencyMs:87,statusCode:200,checkedAt:J},{id:`api-gateway`,name:`API Gateway`,url:`https://api.example.com/readyz`,up:!1,latencyMs:5e3,statusCode:0,checkedAt:J,error:`unexpected_status: upstream 503s`},{id:`cdn-edge`,name:`Edge CDN`,url:`https://cdn.example.com`,up:!0,latencyMs:24,statusCode:200,checkedAt:J}]}function ve(e){let t=e.filter(e=>e.up!==null);return t.length===0?`unknown`:t.every(e=>e.up===!1)?`major_outage`:t.some(e=>e.up===!1)?`partial_outage`:`operational`}function Y(e=null){let t=_e();return e===`operational`?t=t.map(e=>({...e,up:!0,error:void 0})):e===`degraded`?t=t.map((e,t)=>t===1?e:{...e,up:!0,error:void 0}):e===`partial_outage`?t=t.map((e,t)=>t===0?{...e,up:!1,error:`connection refused`}:{...e,up:!0,error:void 0}):e===`major_outage`?t=t.map(e=>({...e,up:!1,error:`connection refused`})):e===`unknown`&&(t=t.map(e=>({...e,up:null,latencyMs:null,statusCode:null,checkedAt:null,error:void 0}))),{checkedAt:W,overall:e??ve(t),services:t}}var ye=[{id:101,serviceId:`api-gateway`,title:`Elevated latency on API Gateway`,state:`monitoring`,severity:`major`,createdAt:K(G(1))-21600,updatedAt:K(G(1))-7200,updates:[{ts:K(G(1))-7200,text:`Latency is back within SLO; monitoring before resolving.`},{ts:K(G(1))-21600,text:`Automated alert: probe failed — unexpected_status: upstream 503s.`}]},{id:99,serviceId:`api-gateway`,title:`Upstream 5xx spike`,state:`resolved`,severity:`major`,createdAt:K(G(9)),updatedAt:K(G(8)),updates:[{ts:K(G(8)),text:`Automated recovery: service is responding normally.`}]},{id:87,title:`Scheduled database maintenance`,state:`resolved`,severity:`minor`,createdAt:K(G(20)),updatedAt:K(G(20))+3600,updates:[{ts:K(G(20))+3600,text:`Maintenance completed ahead of schedule; no customer impact observed.`}]}];function X(e={}){let t=[...ye];return e.serviceId&&(t=t.filter(t=>t.serviceId===e.serviceId)),e.state&&(t=t.filter(t=>t.state===e.state)),e.open!==void 0&&(t=t.filter(t=>e.open?t.state!==`resolved`:t.state===`resolved`)),t.sort((e,t)=>t.createdAt-e.createdAt),{items:t,total:t.length}}function Z({tenant:e,customDomain:t,basePath:n}){let{search:r}=i(),a=O(r),o=D(),[s,c]=(0,d.useState)(90),l=k(async e=>{if(a)return Y(a);try{return await S(e)}catch{return Y(null)}},15e3),u=l.data,f=a===null&&l.error===null,p=(0,d.useMemo)(()=>(u?.services??[]).filter(t=>e.services.includes(t.id)),[u,e]),[m,h]=(0,d.useState)({});(0,d.useEffect)(()=>{let t=!1;async function n(){let n=await Promise.all(e.services.map(async e=>{if(a)return[e,q(e,s)];try{return[e,await C(e,s)]}catch{return[e,q(e,s)]}}));t||h(Object.fromEntries(n))}return n(),()=>{t=!0}},[e,s,a]);let[g,_]=(0,d.useState)(null);(0,d.useEffect)(()=>{let e=!1;async function t(){if(a){e||_(X());return}try{let t=await w();e||_(t)}catch{e||_(X())}}return t(),()=>{e=!0}},[a]);let v=(0,d.useMemo)(()=>(g?.items??[]).filter(t=>t.serviceId===void 0||e.services.includes(t.serviceId)),[g,e]),y=(0,d.useMemo)(()=>{let e={};for(let t of v)t.state!==`resolved`&&t.serviceId!==void 0&&(e[t.serviceId]??=[]).push(t.id);return e},[v]),b=v.filter(e=>e.state!==`resolved`&&e.serviceId===void 0);return(0,j.jsxs)(`div`,{className:`mx-auto max-w-4xl space-y-6 px-4 py-8 sm:py-12`,children:[(0,j.jsx)(me,{tenant:e,customDomain:t}),(0,j.jsxs)(`main`,{"aria-label":`${e.displayName} status`,className:`space-y-6`,children:[u?(0,j.jsx)(I,{overall:u.overall,checkedAt:u.checkedAt,live:f,now:o}):(0,j.jsx)(`p`,{role:`status`,className:`rounded-2xl p-8 text-center text-sm`,style:{border:`1px solid var(--card-border)`,background:`var(--card-bg)`,color:`var(--muted-fg)`},children:l.error?`Status unavailable (${l.error}); retrying…`:`Loading status…`}),(b.length>0||p.some(e=>(y[e.id]??[]).length>0))&&(0,j.jsxs)(`section`,{"aria-label":`Active incidents`,className:`rounded-2xl p-4 sm:p-5`,style:{background:`var(--warn-bg)`,color:`var(--warn-fg)`},children:[(0,j.jsx)(`h2`,{className:`font-bold`,children:b.length>0?`Active incident: ${b[0].title}`:`Active incident — see the affected service below`}),b.length>0&&b[0].updates[0]&&(0,j.jsx)(`p`,{className:`mt-1 text-sm opacity-90`,children:b[0].updates[0].text}),(0,j.jsx)(`p`,{className:`mt-2 text-sm`,children:(0,j.jsx)(`a`,{className:`font-semibold underline underline-offset-2`,href:`#incidents`,children:`Follow along in the incident feed ↓`})})]}),(0,j.jsxs)(`div`,{className:`flex flex-wrap items-end justify-between gap-3`,children:[(0,j.jsx)(`h2`,{className:`text-xl font-bold tracking-tight`,children:`Services`}),(0,j.jsx)(`div`,{role:`group`,"aria-label":`History range`,className:`inline-flex rounded-full p-1`,style:{border:`1px solid var(--card-border)`},children:[30,90].map(e=>(0,j.jsxs)(`button`,{type:`button`,"aria-pressed":s===e,onClick:()=>c(e),className:`tabular rounded-full px-4 py-1.5 font-mono text-xs font-semibold transition`,style:s===e?{background:`var(--fg-active)`,color:`var(--bg-active)`}:{color:`var(--muted-fg)`},children:[e,`d`]},e))})]}),p.map(e=>(0,j.jsx)(V,{service:e,history:m[e.id]??null,activeIncidentIds:y[e.id]??[],now:o},e.id)),(0,j.jsx)(N,{incidents:v,serviceIds:e.services,basePath:n}),(0,j.jsx)(pe,{slug:e.slug,tenantName:e.displayName}),(0,j.jsxs)(`p`,{className:`tabular text-center font-mono text-xs`,style:{color:`var(--muted-fg)`},children:[`Updated `,u?E(u.checkedAt,o):`—`,` · refreshes automatically`,a?` · preview mode (?preview=${a})`:``]})]}),(0,j.jsxs)(`footer`,{className:`border-t pt-4 text-center text-xs`,style:{borderColor:`var(--card-border)`,color:`var(--muted-fg)`},children:[e.domains[0]??`epmon.dev/p/${e.slug}`,` ·`,` `,new Date().getUTCFullYear(),` `,e.displayName]})]})}function Q(){let{domain:e=``}=s(),t=b(e)??y(e);if(!t)return(0,j.jsxs)(`main`,{className:`mx-auto max-w-2xl px-4 py-16 text-center sm:py-24`,children:[(0,j.jsx)(`p`,{className:`font-mono text-xs font-semibold uppercase tracking-[0.25em]`,style:{color:`var(--muted-fg)`},children:`404`}),(0,j.jsx)(`h1`,{className:`mt-3 text-4xl font-extrabold tracking-tight`,children:`No page on this domain.`}),(0,j.jsxs)(`p`,{className:`mt-3 text-sm`,style:{color:`var(--muted-fg)`},children:[`“`,e,`” isn’t serving a status page here. Configured demo domains:`]}),(0,j.jsx)(`ul`,{className:`mt-6 space-y-2`,children:v().flatMap(e=>e.domains.map(t=>(0,j.jsxs)(`li`,{children:[(0,j.jsx)(o,{className:`font-medium underline underline-offset-4`,to:`/s/${t}`,children:t}),` `,(0,j.jsxs)(`span`,{className:`text-sm`,style:{color:`var(--muted-fg)`},children:[`(`,e.displayName,`)`]})]},t)))})]});let n=t.domains[0]??t.slug;return(0,j.jsx)(Z,{tenant:t,customDomain:!0,basePath:`/s/${n}`})}function be(){let[e,t]=(0,d.useState)(`acme-inc`),n=`
\n + * + * Attributes (on the + + + + + + +
+ + + diff --git a/internal/api/webui/source.txt b/internal/api/webui/source.txt new file mode 100644 index 0000000..a343abb --- /dev/null +++ b/internal/api/webui/source.txt @@ -0,0 +1,2 @@ +origin: epmon-dev/status @ 0fa682b-dirty +refreshed: 2026-09-20T13:43:55Z diff --git a/internal/api/webui/webui.go b/internal/api/webui/webui.go new file mode 100644 index 0000000..d801454 --- /dev/null +++ b/internal/api/webui/webui.go @@ -0,0 +1,89 @@ +// Package webui serves the vendored status-page app (spec §9) from the +// epmon binary: the built output of epmon-dev/status, embedded at compile +// time. Runtime stays Node-free — bundling happens once, in the status +// repo; see make web / scripts/refresh-webui.sh for the refresh flow. +// +// Routing contract: exact files win (index.html, /assets/*, /embed.js, +// /favicon.svg); every other path falls back to index.html so the SPA's +// /p/:slug, /s/:domain and /embed routes render. HTML is always +// no-store; hashed /assets/* are immutable; /embed.js is versioned by +// content hash in practice but served with a 1h cap per the app's +// hosting contract. +package webui + +import ( + "bytes" + "embed" + "io/fs" + "net/http" + "path" + "strings" + "time" +) + +//go:embed index.html favicon.svg embed.js assets +var embedded embed.FS + +// files is the embedded tree rooted at the vendored dir. +var files, _ = fs.Sub(embedded, ".") + +// Handler serves the app. Only GET/HEAD; anything else is 405. +func Handler() http.Handler { + fsrv := http.FileServer(http.FS(files)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + p := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/")) + if p == "/" { + // The binary serves one core: land on its services, not + // the multi-tenant demo index. + http.Redirect(w, r, "/local", http.StatusFound) + return + } + if exists(p) { + cachePolicy(w, p) + fsrv.ServeHTTP(w, r) + return + } + // SPA fallback: always the shell, never cached. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + data, err := fs.ReadFile(files, "index.html") + if err != nil { + http.Error(w, "status UI unavailable", http.StatusInternalServerError) + return + } + now := time.Now() + w.Header().Set("Last-Modified", now.UTC().Format(http.TimeFormat)) + http.ServeContent(w, r, "index.html", now, bytes.NewReader(data)) + }) +} + +func exists(p string) bool { + f, err := files.Open(strings.TrimPrefix(p, "/")) + if err != nil { + return false + } + defer f.Close() + st, err := f.Stat() + return err == nil && !st.IsDir() +} + +// cachePolicy assigns Cache-Control per the app's hosting contract: +// hashed assets immutable, badge script bounded, everything else none +// (HTML shell goes through the no-store fallback above). +func cachePolicy(w http.ResponseWriter, p string) { + switch { + case strings.HasPrefix(p, "/assets/"): + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + case p == "/embed.js": + w.Header().Set("Cache-Control", "public, max-age=3600") + case p == "/favicon.svg": + w.Header().Set("Cache-Control", "public, max-age=86400") + default: + w.Header().Set("Cache-Control", "no-store") + } +} diff --git a/internal/api/webui/webui_test.go b/internal/api/webui/webui_test.go new file mode 100644 index 0000000..323c17e --- /dev/null +++ b/internal/api/webui/webui_test.go @@ -0,0 +1,89 @@ +package webui + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// firstAsset finds a vendored bundle at runtime: hashed filenames change +// on every status/ rebuild, so no hash may appear literally here. +func firstAsset(t *testing.T, suffix string) string { + t.Helper() + entries, err := fs.ReadDir(files, "assets") + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), suffix) { + return "/assets/" + e.Name() + } + } + t.Fatalf("no %q asset vendored", suffix) + return "" +} + +func get(t *testing.T, path string) (int, http.Header, string) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + Handler().ServeHTTP(rec, req) + return rec.Code, rec.Header(), rec.Body.String() +} + +func TestRootRedirectsToLocal(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("GET / = %d, want 302", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/local" { + t.Errorf("GET / Location = %q, want /local", loc) + } +} + +func TestIndexAndFallbackAreNoStoreHTML(t *testing.T) { + for _, path := range []string{"/local", "/p/acme", "/s/status.example.com", "/nope", "/embed"} { + code, header, body := get(t, path) + if code != 200 { + t.Errorf("GET %s = %d, want 200", path, code) + } + if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("GET %s Content-Type = %q, want text/html", path, ct) + } + if cc := header.Get("Cache-Control"); cc != "no-store" { + t.Errorf("GET %s Cache-Control = %q, want no-store", path, cc) + } + if !strings.Contains(body, `
`) { + t.Errorf("GET %s is not the app shell", path) + } + } +} + +func TestAssetCachePolicy(t *testing.T) { + code, header, body := get(t, firstAsset(t, ".js")) + if code != 200 || len(body) == 0 { + t.Fatalf("asset = %d bytes, code %d", len(body), code) + } + if cc := header.Get("Cache-Control"); cc != "public, max-age=31536000, immutable" { + t.Errorf("asset Cache-Control = %q", cc) + } + if _, header, _ := get(t, "/embed.js"); header.Get("Cache-Control") != "public, max-age=3600" { + t.Errorf("embed.js Cache-Control = %q", header.Get("Cache-Control")) + } + if code, _, _ := get(t, "/favicon.svg"); code != 200 { + t.Errorf("favicon = %d, want 200", code) + } +} + +func TestMethodNotAllowed(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("POST / = %d, want 405", rec.Code) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3043325..ce7ce24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -451,14 +451,15 @@ func DiscoverPath(flagPath string) string { return "" } -// Default returns built-in defaults with zero services. +// Default returns built-in defaults with zero services. Each default-true +// flag gets its own bool: sharing one address would let a file that sets +// any single flag flip the others through the same pointer. func Default() *Config { - t := true cfg := &Config{} cfg.Server.Listen = ":8080" cfg.Server.ReadTimeout = Duration(10 * time.Second) cfg.Server.WriteTimeout = Duration(10 * time.Second) - cfg.Server.StatusPage.Enabled = &t + cfg.Server.StatusPage.Enabled = boolPtr(true) cfg.Storage.BusyTimeoutMs = 5000 cfg.History.Timezone = "UTC" cfg.API.MaxPageSize = 100 @@ -467,8 +468,8 @@ func Default() *Config { cfg.Probes.FailureThreshold = 1 cfg.Probes.Concurrency = 64 cfg.Probes.MaxBodyBytes = 1 << 20 - cfg.Probes.AutoIncidents = &t - cfg.Incidents.AutoResolve = &t + cfg.Probes.AutoIncidents = boolPtr(true) + cfg.Incidents.AutoResolve = boolPtr(true) cfg.Logging.Level = "info" cfg.Logging.Format = "json" cfg.Database.Driver = "sqlite" @@ -669,9 +670,7 @@ func (c *Config) applyDefaults() error { if c.Incidents.AutoResolve != nil && !*c.Incidents.AutoResolve { warnOncef("unimplemented:auto-resolve", "incidents.auto_resolve=false has no effect yet") } - if c.Server.StatusPage.Enabled != nil && !*c.Server.StatusPage.Enabled { - warnOncef("unimplemented:status-page", "server.status_page.enabled=false has no effect yet") - } + if c.Server.Metrics.RequireAuth { warnOncef("unimplemented:metrics-auth", "server.metrics.require_auth=true has no effect yet (/metrics stays public)") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5890f1b..8236012 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -84,6 +84,34 @@ services: } } +// TestDefaultFlagsAreIndependent guards a real aliasing bug: Default() +// once handed the same *bool to StatusPage.Enabled, AutoIncidents and +// AutoResolve, so a file setting status_page.enabled=false silently +// flipped the other two through the shared address. +func TestDefaultFlagsAreIndependent(t *testing.T) { + path := writeTemp(t, "config.yaml", ` +server: + status_page: + enabled: false +services: + - id: web + url: https://example.com +`) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.StatusPageEnabled() { + t.Error("status page should be disabled") + } + if !cfg.AutoIncidentsOrDefault() { + t.Error("disabling the status page flipped probes.auto_incidents") + } + if !cfg.AutoResolveOrDefault() { + t.Error("disabling the status page flipped incidents.auto_resolve") + } +} + func TestLoadJSON(t *testing.T) { path := writeTemp(t, "config.json", `{ "services": [{"id": "a", "name": "A", "url": "http://localhost:1/", "interval": "30s"}] diff --git a/scripts/check-docs.py b/scripts/check-docs.py new file mode 100644 index 0000000..5e581bd --- /dev/null +++ b/scripts/check-docs.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Every ```yaml block in README.md must load through `epmon validate`. + +Same check CI runs (see .github/workflows/ci.yml). Required env: +EPMON_API_KEY, TOKEN, DEV_TOKEN (any dummy values). +""" +import os +import re +import subprocess +import sys +import tempfile + + +def main() -> int: + with open("README.md") as f: + blocks = re.findall(r"```yaml\n(.*?)```", f.read(), re.S) + if not blocks: + print("no yaml blocks found in README") + return 1 + for i, b in enumerate(blocks): + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f: + f.write(b) + path = f.name + r = subprocess.run( + ["go", "run", "./cmd/epmon", "validate", "--config", path], + capture_output=True, + text=True, + ) + print(f"README yaml block {i}: exit={r.returncode} {r.stderr.strip()}") + os.unlink(path) + if r.returncode != 0: + print(f"README yaml block {i} failed to validate") + return 1 + for example in ("config.example.yaml", "config.example.json"): + r = subprocess.run( + ["go", "run", "./cmd/epmon", "validate", "--config", example], + capture_output=True, + text=True, + ) + print(f"{example}: exit={r.returncode} {r.stderr.strip()}") + if r.returncode != 0: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/refresh-webui.sh b/scripts/refresh-webui.sh new file mode 100755 index 0000000..f81bc34 --- /dev/null +++ b/scripts/refresh-webui.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Refresh internal/api/webui from a status/ checkout (default: ../status). +# Usage: STATUS_DIR=/path/to/status scripts/refresh-webui.sh +# Rebuilds the SPA, vendors index.html + hashed assets + embed.js + +# favicon.svg, and stamps the origin in source.txt. webui tests refuse +# literal hashes, so a refresh never breaks them by renaming files. +set -eu +STATUS_DIR="${STATUS_DIR:-../status}" +DEST="internal/api/webui" + +test -f "$STATUS_DIR/package.json" || { + echo "no status checkout at $STATUS_DIR (override with STATUS_DIR=...)" >&2 + exit 1 +} +(cd "$STATUS_DIR" && npm run build >/dev/null) || exit 1 +rm -rf "$DEST/index.html" "$DEST/favicon.svg" "$DEST/embed.js" "$DEST/assets" +cp "$STATUS_DIR/dist/index.html" "$STATUS_DIR/dist/favicon.svg" \ + "$STATUS_DIR/dist/embed.js" "$DEST/" +cp -R "$STATUS_DIR/dist/assets" "$DEST/assets" +rev="(unknown)" +if [ -d "$STATUS_DIR/.git" ]; then + rev="$(git -C "$STATUS_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)" + if [ -n "$(git -C "$STATUS_DIR" status --short 2>/dev/null)" ]; then + rev="$rev-dirty" + fi +fi +printf 'origin: epmon-dev/status @ %s\nrefreshed: %s\n' "$rev" "$(date -u +%FT%TZ)" > "$DEST/source.txt" +echo "webui refreshed from $STATUS_DIR ($rev)"