mirror of
https://github.com/aziis98/statuspage.git
synced 2026-10-06 14:45:01 +00:00
ui: uptime chip, #0004 shadows, tick marks, raw metrics; server: config meta injection
- ui: add uptime% chip (green >=99, red below) after ssh; remove "up" word - ui: all shadows use #0004 shadow-color; modal shadow-lg derives from it - ui: status modal draws a datetime tick mark every 20 history entries from the newest - server: drop max_points/downsampling/bucketing; metrics served raw, plots share a ~24h window - docs: fix ps --no-headers in top example; document raw metrics API - server: inject <meta name=title/description> into index.html from config title + per-group host patterns (shared with --check recap)
This commit is contained in:
@@ -32,7 +32,7 @@ statuspage
|
||||
│ ├── config.go yaml config loading + defaults
|
||||
│ ├── monitor.go probes, ssh runs, status/history/metrics handlers
|
||||
│ ├── mock.go deterministic mock data for --mock / screenshots
|
||||
│ └── history.go sqlite history + metrics storage and downsampling
|
||||
│ └── history.go sqlite history + metrics storage
|
||||
├── go.mod module definition (statuspage)
|
||||
├── src/ the frontend (Preact + TS + CSS)
|
||||
├── example.config.yaml documented, fully commented-out example config
|
||||
@@ -203,8 +203,8 @@ Controls how metric plots share their x (time) axis:
|
||||
|
||||
- `false` (default): each metric plot scales to its own recorded time span, so
|
||||
a brand new metric isn't drowned out by an older one.
|
||||
- `true`: all plots are aligned to the same window (the last ~24h) and are
|
||||
downsampled together, so you can compare shapes directly.
|
||||
- `true`: all plots are aligned to the same window (the last ~24h), so you can
|
||||
compare shapes directly.
|
||||
|
||||
### `ping`
|
||||
|
||||
@@ -359,7 +359,7 @@ ssh:
|
||||
echo "uptime: $(uptime -p)"
|
||||
echo "load: $(cat /proc/loadavg)"
|
||||
echo "mem: $(free -h | awk '/^Mem:/ {print $3 "/" $2}')"
|
||||
echo "top: $(ps -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
|
||||
echo "top: $(ps --no-headers -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
|
||||
```
|
||||
|
||||
Suggested bounds for the metrics above:
|
||||
@@ -455,10 +455,8 @@ groups:
|
||||
| ------------ | ------------------------------------------------ |
|
||||
| `name` | restrict to one metric name |
|
||||
| `min`, `max` | unix seconds window to fetch |
|
||||
| `max_points` | max returned points (downsampled, default 100) |
|
||||
| `shared` | align downsampling to the `min`/`max` window |
|
||||
|
||||
The frontend asks for the last ~24h at `max_points=100` per metric.
|
||||
The frontend asks for the last ~24h per metric.
|
||||
|
||||
## Storage
|
||||
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
# echo "load: $(cat /proc/loadavg)"
|
||||
# echo "mem: $(free -h | awk '/^Mem:/ {print $3 "/" $2}')"
|
||||
# echo "disk: $(df -h / | awk 'NR==2 {print $4 " free"}')"
|
||||
# echo "top: $(ps -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
|
||||
# echo "top: $(ps --no-headers -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
|
||||
#
|
||||
# Script format, one line per item:
|
||||
# name:status:info -> a check chip (status: on / off / down / anything)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Status Page</title>
|
||||
<!-- statuspage:inject -->
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -165,3 +166,47 @@ machines:
|
||||
t.Errorf("top-level host: %q", p.Machines[0].Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusPayloadUptime(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
cfg := `
|
||||
machines:
|
||||
- host: a3-dott1.cs.dm.unipi.it
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf, err := LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dbPath := filepath.Join(dir, "h.db")
|
||||
hist, err := NewHistory(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer hist.Close()
|
||||
id := conf.Machines[0].ID
|
||||
for i := 0; i < 10; i++ {
|
||||
hist.Record(id, statusUp, "")
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
hist.Record(id, statusDown, "")
|
||||
}
|
||||
hist.Record(id, statusDegraded, "")
|
||||
|
||||
mon := NewMonitor(conf, hist)
|
||||
p := mon.StatusPayload()
|
||||
if len(p.Machines) != 1 {
|
||||
t.Fatalf("want 1 machine, got %d", len(p.Machines))
|
||||
}
|
||||
ms := p.Machines[0]
|
||||
if ms.Uptime == nil {
|
||||
t.Fatal("expected uptime")
|
||||
}
|
||||
want := float64(10) / float64(15) * 100
|
||||
if math.Abs(*ms.Uptime-want) > 0.001 {
|
||||
t.Errorf("uptime %v, want %v", *ms.Uptime, want)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-88
@@ -108,6 +108,16 @@ func (h *History) RecordMetric(machine, name string, ts int64, value float64, un
|
||||
}
|
||||
}
|
||||
|
||||
// Uptime counts the number of 'up' and 'down' history ticks for a machine.
|
||||
// A machine with no history returns zeroes.
|
||||
func (h *History) Uptime(machine string) (up, down int) {
|
||||
h.db.QueryRow(`SELECT
|
||||
COALESCE(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'down' THEN 1 ELSE 0 END), 0)
|
||||
FROM history WHERE machine = ?`, machine).Scan(&up, &down)
|
||||
return
|
||||
}
|
||||
|
||||
func (h *History) Query(machine string, limit int) ([]HistoryEntry, error) {
|
||||
if limit <= 0 || limit > historyPerMachine {
|
||||
limit = historyPerMachine
|
||||
@@ -132,10 +142,7 @@ func (h *History) Query(machine string, limit int) ([]HistoryEntry, error) {
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (h *History) QueryMetrics(machine, name string, minTS, maxTS int64, maxPoints int, shared bool) ([]MetricEntry, error) {
|
||||
if maxPoints <= 0 || maxPoints > metricsPerName*5 {
|
||||
maxPoints = 100
|
||||
}
|
||||
func (h *History) QueryMetrics(machine, name string, minTS, maxTS int64) ([]MetricEntry, error) {
|
||||
q := `SELECT ts, name, value, unit FROM metrics WHERE machine = ?`
|
||||
args := []any{machine}
|
||||
if name != "" {
|
||||
@@ -166,90 +173,7 @@ func (h *History) QueryMetrics(machine, name string, minTS, maxTS int64, maxPoin
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lo, hi int64
|
||||
if shared {
|
||||
lo = minTS
|
||||
hi = maxTS
|
||||
}
|
||||
return downsampleMetrics(entries, lo, hi, maxPoints), nil
|
||||
}
|
||||
|
||||
func downsampleMetrics(entries []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry {
|
||||
if maxPoints < 1 {
|
||||
return entries
|
||||
}
|
||||
groups := map[string][]MetricEntry{}
|
||||
order := []string{}
|
||||
for _, e := range entries {
|
||||
if _, ok := groups[e.Name]; !ok {
|
||||
order = append(order, e.Name)
|
||||
}
|
||||
groups[e.Name] = append(groups[e.Name], e)
|
||||
}
|
||||
out := []MetricEntry{}
|
||||
for _, name := range order {
|
||||
out = append(out, downsampleGroup(groups[name], lo, hi, maxPoints)...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].TS < out[j].TS })
|
||||
return out
|
||||
}
|
||||
|
||||
func downsampleGroup(g []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry {
|
||||
if len(g) <= maxPoints {
|
||||
return g
|
||||
}
|
||||
return alignGroup(g, lo, hi, maxPoints)
|
||||
}
|
||||
|
||||
// alignGroup downsamples g to at most maxPoints buckets whose boundaries are
|
||||
// aligned to the [lo, hi] window, so all machines share the same bucket centers.
|
||||
func alignGroup(g []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry {
|
||||
if len(g) == 0 {
|
||||
return nil
|
||||
}
|
||||
if lo <= 0 {
|
||||
lo = g[0].TS
|
||||
}
|
||||
if hi <= 0 {
|
||||
hi = g[len(g)-1].TS
|
||||
}
|
||||
span := hi - lo
|
||||
if span <= 0 {
|
||||
span = 1
|
||||
}
|
||||
bucketSize := float64(span) / float64(maxPoints)
|
||||
buckets := make([][]MetricEntry, maxPoints)
|
||||
for _, e := range g {
|
||||
idx := int(float64(e.TS-lo) / bucketSize)
|
||||
if idx >= maxPoints {
|
||||
idx = maxPoints - 1
|
||||
}
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
buckets[idx] = append(buckets[idx], e)
|
||||
}
|
||||
res := make([]MetricEntry, 0, maxPoints)
|
||||
for i, b := range buckets {
|
||||
if len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
var sum float64
|
||||
for _, e := range b {
|
||||
sum += e.Value
|
||||
}
|
||||
last := b[len(b)-1]
|
||||
res = append(res, MetricEntry{
|
||||
TS: lo + int64((float64(i)+0.5)*bucketSize),
|
||||
Name: last.Name,
|
||||
Value: sum / float64(len(b)),
|
||||
Unit: last.Unit,
|
||||
})
|
||||
}
|
||||
return res
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (h *History) QueryMetricsAll(minTS, maxTS int64) ([]MetricAggregate, [2]int64, error) {
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// indexPlaceholder marks the spot in index.html where the server injects the
|
||||
// title/description meta tags derived from the loaded config.
|
||||
const indexPlaceholder = "<!-- statuspage:inject -->"
|
||||
|
||||
// maxSummaryNames caps how many host patterns a single group line shows before
|
||||
// collapsing into "+N more".
|
||||
const maxSummaryNames = 4
|
||||
|
||||
// machineSummary returns one line per configured group with its machine count
|
||||
// and the distinct host patterns it was expanded from (falling back to machine
|
||||
// names for top-level machines), mirroring the per-group recap printed by
|
||||
// --check.
|
||||
func machineSummary(cfg *Config) []string {
|
||||
byGroup := map[string]int{}
|
||||
patterns := map[string][]string{}
|
||||
seen := map[string]map[string]bool{}
|
||||
for _, m := range cfg.Machines {
|
||||
byGroup[m.Group]++
|
||||
}
|
||||
for _, g := range cfg.Groups {
|
||||
seen[g.Name] = map[string]bool{}
|
||||
for _, mc := range g.Machines {
|
||||
if !seen[g.Name][mc.Host] {
|
||||
seen[g.Name][mc.Host] = true
|
||||
patterns[g.Name] = append(patterns[g.Name], mc.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
lines := make([]string, 0, len(cfg.Groups)+1)
|
||||
for _, g := range cfg.Groups {
|
||||
lines = append(lines, formatGroupSummary(g.Name, byGroup[g.Name], patterns[g.Name]))
|
||||
}
|
||||
if n := byGroup[""]; n > 0 {
|
||||
names := []string{}
|
||||
s := map[string]bool{}
|
||||
for _, m := range cfg.Machines {
|
||||
if m.Group == "" && !s[m.Name] {
|
||||
s[m.Name] = true
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
lines = append(lines, formatGroupSummary("(top-level)", n, names))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func formatGroupSummary(name string, n int, items []string) string {
|
||||
line := fmt.Sprintf("%s: %d", name, n)
|
||||
if len(items) == 0 {
|
||||
return line
|
||||
}
|
||||
show := items
|
||||
extra := ""
|
||||
if len(items) > maxSummaryNames {
|
||||
show = items[:maxSummaryNames]
|
||||
extra = fmt.Sprintf(" +%d more", len(items)-maxSummaryNames)
|
||||
}
|
||||
return fmt.Sprintf("%s (%s%s)", line, strings.Join(show, ", "), extra)
|
||||
}
|
||||
|
||||
func metaTags(title, description string) string {
|
||||
if description == "" {
|
||||
description = "status page"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"<meta name=\"title\" content=\"%s\" />\n <meta name=\"description\" content=\"%s\" />",
|
||||
html.EscapeString(title), html.EscapeString(description),
|
||||
)
|
||||
}
|
||||
|
||||
// indexHandler serves the built SPA, replacing indexPlaceholder in index.html
|
||||
// with config-derived meta tags. All other paths are delegated to the static
|
||||
// file server.
|
||||
func indexHandler(dist, title, description string) http.Handler {
|
||||
indexPath := filepath.Join(dist, "index.html")
|
||||
index, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
log.Fatalf("reading %s: %v", indexPath, err)
|
||||
}
|
||||
body := strings.Replace(string(index), indexPlaceholder, metaTags(title, description), 1)
|
||||
fs := http.FileServer(http.Dir(dist))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
io.WriteString(w, body)
|
||||
})
|
||||
}
|
||||
+13
-21
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/pflag"
|
||||
@@ -20,16 +21,9 @@ func envOr(key, def string) string {
|
||||
}
|
||||
|
||||
func printConfigSummary(path string, cfg *Config) {
|
||||
byGroup := map[string]int{}
|
||||
for _, m := range cfg.Machines {
|
||||
byGroup[m.Group]++
|
||||
}
|
||||
log.Printf("config %s ok: %d machines", path, len(cfg.Machines))
|
||||
for _, g := range cfg.Groups {
|
||||
log.Printf(" %s: %d", g.Name, byGroup[g.Name])
|
||||
}
|
||||
if n := byGroup[""]; n > 0 {
|
||||
log.Printf(" (top-level): %d", n)
|
||||
for _, line := range machineSummary(cfg) {
|
||||
log.Printf(" %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,10 +39,6 @@ func buildFrontend() error {
|
||||
return c.Run()
|
||||
}
|
||||
|
||||
func spaHandler() http.Handler {
|
||||
return http.FileServer(http.Dir("dist"))
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
@@ -76,6 +66,14 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("loading config: %v", err)
|
||||
}
|
||||
log.Printf("loaded %d machines from %s", len(cfg.Machines), *configPath)
|
||||
title := cfg.Title
|
||||
desc := strings.Join(machineSummary(cfg), ", ")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
if *mock {
|
||||
@@ -87,7 +85,7 @@ func main() {
|
||||
mux.HandleFunc("GET /api/metrics/{machine}", mm.metricsHandler)
|
||||
mux.HandleFunc("POST /api/refresh/{machine}", mm.refreshHandler)
|
||||
if !*devServer {
|
||||
mux.Handle("GET /", spaHandler())
|
||||
mux.Handle("GET /", indexHandler("dist", title, desc))
|
||||
}
|
||||
log.Printf("listening on %s (mock)", *addr)
|
||||
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||
@@ -102,12 +100,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("loading config: %v", err)
|
||||
}
|
||||
log.Printf("loaded %d machines from %s", len(cfg.Machines), *configPath)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -127,7 +119,7 @@ func main() {
|
||||
mux.HandleFunc("GET /api/metrics/{machine}", mon.metricsHandler)
|
||||
mux.HandleFunc("POST /api/refresh/{machine}", mon.refreshHandler)
|
||||
if !*devServer {
|
||||
mux.Handle("GET /", spaHandler())
|
||||
mux.Handle("GET /", indexHandler("dist", title, desc))
|
||||
}
|
||||
|
||||
log.Printf("listening on %s", *addr)
|
||||
|
||||
@@ -136,10 +136,25 @@ func (mm *MockMonitor) sshResult(m mockMachine, now time.Time) *sshResultState {
|
||||
return &sshResultState{Ok: true, Result: sb.String(), ExitCode: nil, LastRun: now}
|
||||
}
|
||||
|
||||
func mockUptime(m mockMachine) *float64 {
|
||||
switch m.Status {
|
||||
case statusDown:
|
||||
u := 55.0 + float64(mockSeed(m.ID)%20)
|
||||
return &u
|
||||
case statusDegraded:
|
||||
u := 92.0 + float64(mockSeed(m.ID)%7)
|
||||
return &u
|
||||
default:
|
||||
u := 99.0 + float64(mockSeed(m.ID)%10)/10
|
||||
return &u
|
||||
}
|
||||
}
|
||||
|
||||
func (mm *MockMonitor) machineStatus(m mockMachine, now time.Time) machineStatus {
|
||||
ms := machineStatus{
|
||||
ID: m.ID, Name: m.Name, Host: m.Host, Status: m.Status, LastPing: &now,
|
||||
IP: m.IP, IPs: []string{m.IP},
|
||||
Uptime: mockUptime(m),
|
||||
}
|
||||
switch m.Status {
|
||||
case statusDown:
|
||||
|
||||
+9
-11
@@ -77,6 +77,7 @@ type machineStatus struct {
|
||||
ICMP string `json:"icmp"`
|
||||
TCP string `json:"tcp"`
|
||||
LastPing *time.Time `json:"lastPing"`
|
||||
Uptime *float64 `json:"uptime,omitempty"`
|
||||
SSHConfigured bool `json:"sshConfigured"`
|
||||
SSH *sshResultState `json:"ssh"`
|
||||
Metrics []metricStatus `json:"metrics"`
|
||||
@@ -648,6 +649,13 @@ func (m *Monitor) StatusPayload() statusPayload {
|
||||
ms.ICMP = checkNA
|
||||
ms.TCP = checkNA
|
||||
}
|
||||
if m.history != nil {
|
||||
up, down := m.history.Uptime(mc.ID)
|
||||
if up+down > 0 {
|
||||
u := float64(up) / float64(up+down) * 100
|
||||
ms.Uptime = &u
|
||||
}
|
||||
}
|
||||
if group == "" {
|
||||
p.Machines = append(p.Machines, ms)
|
||||
return
|
||||
@@ -703,16 +711,6 @@ func (m *Monitor) metricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
maxPoints := 100
|
||||
if v := q.Get("max_points"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
maxPoints = n
|
||||
}
|
||||
} else if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
maxPoints = n
|
||||
}
|
||||
}
|
||||
var minTS, maxTS int64
|
||||
if v := q.Get("min"); v != "" {
|
||||
minTS, _ = strconv.ParseInt(v, 10, 64)
|
||||
@@ -720,7 +718,7 @@ func (m *Monitor) metricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if v := q.Get("max"); v != "" {
|
||||
maxTS, _ = strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
entries, err := m.history.QueryMetrics(machine, q.Get("name"), minTS, maxTS, maxPoints, q.Get("shared") != "")
|
||||
entries, err := m.history.QueryMetrics(machine, q.Get("name"), minTS, maxTS)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -51,6 +51,12 @@ export function MachineCard({
|
||||
<Chip label="ping" state={m.icmp} />
|
||||
<Chip label="tcp" state={m.tcp} />
|
||||
{m.sshConfigured && <Chip label="ssh" state={sshState} />}
|
||||
{m.uptime !== undefined && (
|
||||
<Chip
|
||||
label={`${m.uptime.toFixed(1)}%`}
|
||||
state={m.uptime >= 99 ? 'ok' : 'fail'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="popover" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -64,6 +70,12 @@ export function MachineCard({
|
||||
<span class="popover-meta-key">last ping</span>
|
||||
<span class="popover-meta-val">{formatTime(m.lastPing)}</span>
|
||||
</span>
|
||||
<span class="popover-meta-item">
|
||||
<span class="popover-meta-key">uptime</span>
|
||||
<span class="popover-meta-val">
|
||||
{m.uptime !== undefined ? `${m.uptime.toFixed(1)}%` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="popover-status">
|
||||
<Dot status={m.status} />
|
||||
|
||||
@@ -57,11 +57,10 @@ export function StatusModal({
|
||||
setMetricEntries(null)
|
||||
const nowS = Math.floor(Date.now() / 1000)
|
||||
const min = nowS - 86400
|
||||
const shared = sharedMetricWindow ? '&shared=1' : ''
|
||||
Promise.all(
|
||||
metricNames.map((name) =>
|
||||
fetch(
|
||||
`/api/metrics/${encodeURIComponent(m.id)}?name=${encodeURIComponent(name)}&min=${min}&max=${nowS}&max_points=100${shared}`
|
||||
`/api/metrics/${encodeURIComponent(m.id)}?name=${encodeURIComponent(name)}&min=${min}&max=${nowS}`
|
||||
).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
)
|
||||
)
|
||||
@@ -78,7 +77,7 @@ export function StatusModal({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [m.id, metricNames.join(','), sharedMetricWindow])
|
||||
}, [m.id, metricNames.join(',')])
|
||||
|
||||
const metricPlots = (() => {
|
||||
if (!metricEntries) return null
|
||||
@@ -247,24 +246,23 @@ export function StatusModal({
|
||||
</div>
|
||||
{hist && hist.length > 0 && (
|
||||
<div class="ticks" ref={ticksRef}>
|
||||
{ticks.map((e, i) => {
|
||||
const step = Math.max(1, Math.round(ticks.length / 4))
|
||||
const mark =
|
||||
i === 0 ||
|
||||
i === ticks.length - 1 ||
|
||||
i % step === 0
|
||||
return (
|
||||
<span key={i} class="tick-cell">
|
||||
<span
|
||||
class={`tick ${e.status}`}
|
||||
title={`${formatTick(e.ts)} · ${e.status}${e.ip ? ` · ${e.ip}` : ''}`}
|
||||
/>
|
||||
{mark && (
|
||||
<span class="tick-mark">{formatTickShort(e.ts)}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{(() => {
|
||||
const last = ticks.length - 1
|
||||
return ticks.map((e, i) => {
|
||||
const mark = (last - i) % 20 === 0
|
||||
return (
|
||||
<span key={i} class="tick-cell">
|
||||
<span
|
||||
class={`tick ${e.status}`}
|
||||
title={`${formatTick(e.ts)} · ${e.status}${e.ip ? ` · ${e.ip}` : ''}`}
|
||||
/>
|
||||
{mark && (
|
||||
<span class="tick-mark">{formatTickShort(e.ts)}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{hist && hist.length > 0 && (
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
--border-ink: #3a403a;
|
||||
--border: 2px solid var(--border-ink);
|
||||
--border-muted: #3a403a;
|
||||
--shadow-color: #000;
|
||||
--shadow-color: #0004;
|
||||
--shadow: 0.35rem 0.35rem 0 var(--shadow-color);
|
||||
--shadow-lg: 0.55rem 0.55rem 0 var(--shadow-color);
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@
|
||||
--border-ink: #111;
|
||||
--border: 2px solid var(--border-ink);
|
||||
--border-muted: #bbb;
|
||||
--shadow-color: #888;
|
||||
--shadow-color: #0004;
|
||||
--shadow: 0.35rem 0.35rem 0 var(--shadow-color);
|
||||
--shadow-lg: 0.55rem 0.55rem 0 var(--shadow-color);
|
||||
--overlay: rgba(0, 0, 0, 0.35);
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Machine {
|
||||
icmp: Check
|
||||
tcp: Check
|
||||
lastPing: string | null
|
||||
uptime?: number
|
||||
sshConfigured: boolean
|
||||
ssh: SshResult | null
|
||||
metrics: MetricStatus[]
|
||||
|
||||
Reference in New Issue
Block a user