chore: auto-commit uncommitted changes

This commit is contained in:
James 2026-03-26 00:01:24 -04:00
parent 55699985ae
commit cb7c7c51ce
27 changed files with 194 additions and 358 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
clavis/.DS_Store vendored

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 118 B

After

Width:  |  Height:  |  Size: 118 B

View File

Before

Width:  |  Height:  |  Size: 87 B

After

Width:  |  Height:  |  Size: 87 B

View File

Before

Width:  |  Height:  |  Size: 100 B

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

View File

@ -1,7 +1,6 @@
package api
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
@ -877,80 +876,6 @@ func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, http.StatusOK, matches)
}
// MapFields uses LLM to map vault fields to form fields.
func (h *Handlers) MapFields(w http.ResponseWriter, r *http.Request) {
if h.Cfg.FireworksAPIKey == "" {
ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "LLM not configured — set LLM_API_KEY in your environment")
return
}
var req struct {
EntryID lib.HexID `json:"entry_id"`
PageFields []struct {
Selector string `json:"selector"`
Label string `json:"label"`
Type string `json:"type"`
Placeholder string `json:"placeholder"`
} `json:"page_fields"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
entry, err := lib.EntryGet(h.db(r), h.vk(r), int64(req.EntryID))
if err != nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
if entry.VaultData == nil {
ErrorResponse(w, http.StatusBadRequest, "no_data", "Entry has no data")
return
}
// Build field lists for LLM
var vaultFields []string
for _, f := range entry.VaultData.Fields {
if !f.L2 { // Only include L1 fields
vaultFields = append(vaultFields, f.Label)
}
}
var formFields []string
for _, f := range req.PageFields {
desc := f.Selector
if f.Label != "" {
desc = f.Label + " (" + f.Selector + ")"
}
formFields = append(formFields, desc)
}
// Call LLM
prompt := fmt.Sprintf(`Map these vault fields to these HTML form fields. Return JSON object mapping vault_field_label to css_selector.
Vault fields: %s
Form fields: %s
Return ONLY valid JSON, no explanation. Example: {"Username":"#email","Password":"#password"}`,
strings.Join(vaultFields, ", "),
strings.Join(formFields, ", "))
llmResp, err := callLLM(h.Cfg, "You are a field mapping assistant. Map credential fields to form fields.", prompt)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "llm_failed", "LLM request failed")
return
}
// Parse LLM response
var mapping map[string]string
if err := json.Unmarshal([]byte(llmResp), &mapping); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response")
return
}
JSONResponse(w, http.StatusOK, mapping)
}
// ---------------------------------------------------------------------------
// Import
@ -977,22 +902,12 @@ func (h *Handlers) ImportEntries(w http.ResponseWriter, r *http.Request) {
return
}
// Try direct parsers first (fast, free, handles 12k+ entries)
var entries []lib.VaultData
if parsed, ok := lib.DetectAndParse(content); ok {
entries = parsed
} else {
// Unknown format — LLM in chunks of 100 rows
if h.Cfg.FireworksAPIKey == "" {
ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "Unknown import format and LLM not configured — set LLM_API_KEY to enable AI-assisted import")
return
}
entries, err = parseLLMFormat(h.Cfg, content)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "llm_failed", err.Error())
return
}
lib.AutoL2Fields(entries)
ErrorResponse(w, http.StatusBadRequest, "unknown_format", "Unsupported import format. Supported: Chrome CSV, Firefox CSV, Bitwarden JSON, Proton Pass JSON")
return
}
// Classify entries against existing vault
@ -1063,164 +978,6 @@ func (h *Handlers) ImportEntries(w http.ResponseWriter, r *http.Request) {
})
}
// parseLLMFormat detects the column mapping of an unknown format using the LLM,
// then maps all rows client-side. Only sends headers + 1 masked sample row to the LLM —
// never actual credential values.
func parseLLMFormat(cfg *lib.Config, content []byte) ([]lib.VaultData, error) {
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
if len(lines) < 2 {
return nil, fmt.Errorf("file too short to detect format")
}
header := lines[0]
// Build a masked sample row — replace all values with their column name
// so the LLM sees structure, never real data.
sampleRow := maskSampleRow(header, lines[1])
prompt := fmt.Sprintf(`You are analyzing a password manager export format.
Here is the header row and ONE masked sample row (values replaced with column names no real data):
Header: %s
Sample: %s
Map each column to our vault schema. Return ONLY this JSON, no explanation:
{
"title": "<column name for entry title>",
"url": "<column name for URL, or null>",
"username": "<column name for username/email, or null>",
"password": "<column name for password, or null>",
"totp": "<column name for TOTP/2FA secret, or null>",
"notes": "<column name for notes, or null>",
"extra_fields": ["<any other column names worth keeping>"]
}`, header, sampleRow)
resp, err := callLLM(cfg, "You are a data format analyzer. Return only JSON.", prompt)
if err != nil {
return nil, fmt.Errorf("LLM format detection failed: %w", err)
}
// Parse the column mapping
start := strings.Index(resp, "{")
end := strings.LastIndex(resp, "}")
if start < 0 || end <= start {
return nil, fmt.Errorf("LLM returned invalid JSON mapping")
}
var mapping struct {
Title string `json:"title"`
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
Notes string `json:"notes"`
ExtraFields []string `json:"extra_fields"`
}
if err := json.Unmarshal([]byte(resp[start:end+1]), &mapping); err != nil {
return nil, fmt.Errorf("failed to parse LLM mapping: %w", err)
}
// Parse header into column index map
cols := parseCSVLine(header)
idx := map[string]int{}
for i, col := range cols {
idx[strings.TrimSpace(col)] = i
}
col := func(name string, row []string) string {
if name == "" {
return ""
}
if i, ok := idx[name]; ok && i < len(row) {
return strings.TrimSpace(row[i])
}
return ""
}
// Map all data rows using the detected column mapping
var entries []lib.VaultData
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
row := parseCSVLine(line)
title := col(mapping.Title, row)
if title == "" {
title = col(mapping.URL, row)
}
if title == "" {
title = "Imported entry"
}
vd := lib.VaultData{
Title: title,
Type: lib.TypeCredential,
}
if u := col(mapping.URL, row); u != "" {
vd.URLs = []string{u}
}
if v := col(mapping.Username, row); v != "" {
vd.Fields = append(vd.Fields, lib.VaultField{Label: "username", Value: v, Kind: "text"})
}
if v := col(mapping.Password, row); v != "" {
vd.Fields = append(vd.Fields, lib.VaultField{Label: "password", Value: v, Kind: "password"})
}
if v := col(mapping.TOTP, row); v != "" {
vd.Fields = append(vd.Fields, lib.VaultField{Label: "totp", Value: v, Kind: "totp"})
}
for _, extra := range mapping.ExtraFields {
if v := col(extra, row); v != "" {
vd.Fields = append(vd.Fields, lib.VaultField{Label: extra, Value: v, Kind: "text"})
}
}
if v := col(mapping.Notes, row); v != "" {
vd.Notes = v
}
entries = append(entries, vd)
}
return entries, nil
}
// maskSampleRow replaces each CSV value in a data row with its corresponding header name.
// Result is safe to send to an LLM — no actual credential data.
func maskSampleRow(header, dataRow string) string {
headers := parseCSVLine(header)
values := parseCSVLine(dataRow)
masked := make([]string, len(headers))
for i, h := range headers {
if i < len(values) && values[i] != "" {
masked[i] = "<" + strings.TrimSpace(h) + ">"
} else {
masked[i] = ""
}
}
return strings.Join(masked, ",")
}
// parseCSVLine parses a single CSV line respecting quoted fields.
func parseCSVLine(line string) []string {
var fields []string
var cur strings.Builder
inQuote := false
for i := 0; i < len(line); i++ {
c := line[i]
if c == '"' {
inQuote = !inQuote
} else if c == ',' && !inQuote {
fields = append(fields, cur.String())
cur.Reset()
} else {
cur.WriteByte(c)
}
}
fields = append(fields, cur.String())
return fields
}
// ImportConfirm confirms and saves imported entries.
func (h *Handlers) ImportConfirm(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
@ -1914,49 +1671,6 @@ func extractDomain(urlStr string) string {
return urlStr
}
func callLLM(cfg *lib.Config, system, user string) (string, error) {
reqBody := map[string]any{
"model": cfg.LLMModel,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"max_tokens": 4096,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", cfg.LLMBaseURL+"/chat/completions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+cfg.FireworksAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Error.Message != "" {
return "", fmt.Errorf("LLM error: %s", result.Error.Message)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("no response from LLM")
}
return result.Choices[0].Message.Content, nil
}
// generateTOTPSecret generates a new TOTP secret.
func generateTOTPSecret() string {
b := make([]byte, 20)

View File

@ -37,7 +37,6 @@ func newTestClient(t *testing.T) *tc {
cfg := &lib.Config{
Port: "0",
DataDir: tmpDir,
Mode: "self-hosted",
SessionTTL: 86400,
}

View File

@ -207,7 +207,6 @@ func mountAPIRoutes(r chi.Router, h *Handlers) {
// Extension API
r.Get("/ext/totp/{id}", h.GetTOTP)
r.Get("/ext/match", h.MatchURL)
r.Post("/ext/map", h.MapFields)
// MCP Token management
r.Post("/mcp-tokens", h.HandleCreateMCPToken)

Binary file not shown.

Binary file not shown.

View File

@ -27,7 +27,8 @@ var (
func main() {
api.Version = version + " (" + commit + " " + buildDate + ")"
// Telemetry flags (all optional — without them, no telemetry runs).
port := flag.Int("port", envInt("PORT", 1984), "Listen port")
telemetryFreq := flag.Int("telemetry-freq", envInt("TELEMETRY_FREQ", 0), "Telemetry POST interval in seconds (0 = disabled)")
telemetryHost := flag.String("telemetry-host", envStr("TELEMETRY_HOST", ""), "Telemetry endpoint URL")
telemetryToken := flag.String("telemetry-token", envStr("TELEMETRY_TOKEN", ""), "Bearer token for telemetry endpoint")
@ -37,6 +38,7 @@ func main() {
if err != nil {
log.Fatalf("config: %v", err)
}
cfg.Port = strconv.Itoa(*port)
// Start telemetry reporter if configured.
lib.StartTelemetry(lib.TelemetryConfig{
@ -44,7 +46,6 @@ func main() {
Host: *telemetryHost,
Token: *telemetryToken,
DataDir: cfg.DataDir,
Mode: cfg.Mode,
Version: version,
})

Binary file not shown.

View File

@ -6,55 +6,21 @@ import (
// Config holds application configuration.
type Config struct {
Port string // default "1984"
DataDir string // directory for vault DB files
Mode string // "self-hosted" (default) or "hosted"
FireworksAPIKey string
LLMBaseURL string // OpenAI-compatible base URL
LLMModel string // default llama-v3p3-70b-instruct
SessionTTL int64 // default 86400 (24 hours)
Port string // default "1984"
DataDir string // directory for vault DB files
SessionTTL int64 // default 86400 (24 hours)
}
// LoadConfig loads configuration from environment variables.
func LoadConfig() (*Config, error) {
port := os.Getenv("PORT")
if port == "" {
port = "1984"
}
mode := os.Getenv("VAULT_MODE")
if mode == "" {
mode = "self-hosted"
}
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "."
}
fireworksKey := os.Getenv("LLM_API_KEY")
if fireworksKey == "" {
fireworksKey = os.Getenv("FIREWORKS_API_KEY") // legacy
}
llmModel := os.Getenv("LLM_MODEL")
if llmModel == "" {
llmModel = "accounts/fireworks/models/llama-v3p3-70b-instruct"
}
llmBaseURL := os.Getenv("LLM_BASE_URL")
if llmBaseURL == "" {
llmBaseURL = "https://api.fireworks.ai/inference/v1"
}
sessionTTL := int64(86400) // 24 hours default
return &Config{
Port: port,
DataDir: dataDir,
Mode: mode,
FireworksAPIKey: fireworksKey,
LLMBaseURL: llmBaseURL,
LLMModel: llmModel,
SessionTTL: sessionTTL,
Port: "1984",
DataDir: dataDir,
SessionTTL: 86400,
}, nil
}

View File

@ -23,7 +23,6 @@ type TelemetryConfig struct {
Host string // e.g. https://hq.clavitor.com/telemetry
Token string // Bearer token for auth
DataDir string // vault data directory (to scan DBs)
Mode string // "self-hosted" or "hosted"
Version string // build version string
}
@ -35,7 +34,6 @@ type TelemetryPayload struct {
Timestamp string `json:"timestamp"`
System SystemMetrics `json:"system"`
Vaults VaultMetrics `json:"vaults"`
Mode string `json:"mode"`
}
type SystemMetrics struct {
@ -91,7 +89,6 @@ func CollectPayload(cfg TelemetryConfig, startTime time.Time) TelemetryPayload {
Timestamp: time.Now().UTC().Format(time.RFC3339),
System: collectSystemMetrics(cfg.DataDir),
Vaults: collectVaultMetrics(cfg.DataDir),
Mode: cfg.Mode,
}
}

View File

@ -16,7 +16,6 @@ func TestCollectPayload(t *testing.T) {
Host: "http://localhost:9999",
Token: "test-token",
DataDir: t.TempDir(),
Mode: "self-hosted",
}
startTime := time.Now().Add(-5 * time.Minute)
@ -34,9 +33,6 @@ func TestCollectPayload(t *testing.T) {
if payload.Timestamp == "" {
t.Error("timestamp should not be empty")
}
if payload.Mode != "self-hosted" {
t.Errorf("mode should be self-hosted, got %s", payload.Mode)
}
if payload.System.OS == "" {
t.Error("OS should not be empty")
}
@ -81,7 +77,6 @@ func TestPostTelemetry(t *testing.T) {
Host: server.URL,
Token: "secret-token",
DataDir: t.TempDir(),
Mode: "hosted",
}
StartTelemetry(cfg)
@ -95,9 +90,6 @@ func TestPostTelemetry(t *testing.T) {
if authHeader != "Bearer secret-token" {
t.Errorf("expected Bearer secret-token, got %q", authHeader)
}
if received.Mode != "hosted" {
t.Errorf("expected mode=hosted, got %q", received.Mode)
}
if received.Version == "" {
t.Error("version should not be empty")
}

BIN
clavitor.com/.DS_Store vendored

Binary file not shown.

View File

@ -29,10 +29,26 @@ build-prod:
# Deploy to prod (Zürich — clavitor.ai)
deploy-prod: build-prod
scp $(BINARY)-linux-amd64 clavitor.db $(PROD_HOST):$(PROD_DIR)/
scp $(BINARY)-linux-amd64 $(PROD_HOST):$(PROD_DIR)/
ssh $(PROD_HOST) "sqlite3 $(PROD_DIR)/clavitor.db \"INSERT INTO maintenance (start_at, reason, started_by, ended_by) VALUES (strftime('%s','now'), 'deploy', 'makefile', '')\""
ssh $(PROD_HOST) "cd $(PROD_DIR) && mv $(BINARY)-linux-amd64 $(BINARY) && systemctl restart clavitor-web"
sleep 5
ssh $(PROD_HOST) "sqlite3 $(PROD_DIR)/clavitor.db \"UPDATE maintenance SET end_at = strftime('%s','now'), ended_by = 'makefile' WHERE end_at IS NULL\""
@echo "✓ prod deployed → https://clavitor.ai"
# Pull prod DB backup locally
backup-prod:
scp $(PROD_HOST):$(PROD_DIR)/clavitor.db backups/clavitor-$(shell date +%Y%m%d-%H%M%S).db
@echo "✓ prod DB backed up"
# One-time: push local DB to prod (DESTRUCTIVE — overwrites prod data)
push-db:
@echo "⚠ This overwrites the prod database. Press Ctrl+C to cancel."
@sleep 3
scp clavitor.db $(PROD_HOST):$(PROD_DIR)/
ssh $(PROD_HOST) "systemctl restart clavitor-web"
@echo "✓ DB pushed to prod"
# First-time prod setup (already done)
setup-prod:
ssh $(PROD_HOST) "mkdir -p $(PROD_DIR)"

View File

@ -281,14 +281,17 @@ func main() {
t.NodeID, t.Version, t.Hostname, t.UptimeSeconds, t.CPUPercent, t.MemTotalMB, t.MemUsedMB, t.DiskTotalMB, t.DiskUsedMB, t.Load1m, t.VaultCount, t.VaultSizeMB, t.VaultEntries, t.Mode)
// Uptime span tracking: extend existing span or create new one
// Gap threshold: 60s (2x the 30s interval, allows one missed beat)
// During maintenance: always extend, never create new (no false gaps)
// Normal: gap threshold 60s (2x the 30s interval, allows one missed beat)
now := time.Now().Unix()
var inMaint bool
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
var spanID int64
var spanEnd int64
err = db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, t.NodeID).Scan(&spanID, &spanEnd)
if err == nil && (now-spanEnd) <= 60 {
if err == nil && (inMaint || (now-spanEnd) <= 60) {
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
} else {
} else if !inMaint {
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
}
@ -438,6 +441,7 @@ func main() {
// Sum span overlap with this day
var upSeconds int64
var hasSpans bool
var lastSpanEnd int64
if rows, err := db.Query(`SELECT start_at, end_at FROM uptime_spans WHERE node_id = ? AND end_at > ? AND start_at < ? ORDER BY start_at`,
nodeID, dsUnix, deUnix); err == nil {
for rows.Next() {
@ -447,9 +451,14 @@ func main() {
if s < dsUnix { s = dsUnix }
if e > deUnix { e = deUnix }
if e > s { upSeconds += e - s }
lastSpanEnd = e
}
rows.Close()
}
// If the trailing gap to now is within heartbeat interval, count it as up
if lastSpanEnd > 0 && (deUnix-lastSpanEnd) <= 60 {
upSeconds += deUnix - lastSpanEnd
}
// No spans at all for this day = no data (node didn't exist yet)
if !hasSpans {
@ -654,14 +663,72 @@ func main() {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
var lastBeat int64
db.QueryRow(`SELECT MAX(received_at) FROM telemetry`).Scan(&lastBeat)
json.NewEncoder(w).Encode(map[string]any{
"overall": overall,
"nodes": nodes,
"incidents": incidents,
"dates": dates,
"overall": overall,
"nodes": nodes,
"incidents": incidents,
"dates": dates,
"last_heartbeat": lastBeat,
})
})
// Status API — day spans for tooltip
http.HandleFunc("/status/api/spans", func(w http.ResponseWriter, r *http.Request) {
node := r.URL.Query().Get("node")
date := r.URL.Query().Get("date")
if node == "" || date == "" {
http.Error(w, `{"error":"missing node or date"}`, 400)
return
}
dayStart, _ := time.Parse("2006-01-02", date)
dayEnd := dayStart.Add(24 * time.Hour)
dsUnix := dayStart.Unix()
deUnix := dayEnd.Unix()
if deUnix > time.Now().Unix() {
deUnix = time.Now().Unix()
}
type Span struct {
Start int64 `json:"start"`
End int64 `json:"end"`
Type string `json:"type"` // "up" or "down"
}
var spans []Span
rows, err := db.Query(`SELECT start_at, end_at FROM uptime_spans WHERE node_id = ? AND end_at > ? AND start_at < ? ORDER BY start_at`, node, dsUnix, deUnix)
if err == nil {
prev := dsUnix
for rows.Next() {
var s, e int64
rows.Scan(&s, &e)
if s < dsUnix { s = dsUnix }
if e > deUnix { e = deUnix }
if s > prev {
spans = append(spans, Span{Start: prev, End: s, Type: "down"})
}
spans = append(spans, Span{Start: s, End: e, Type: "up"})
prev = e
}
rows.Close()
// Only mark trailing gap as "down" if it's significant (>60s)
// Gaps within heartbeat interval are just "not yet reported"
if prev < deUnix && (deUnix-prev) > 60 {
spans = append(spans, Span{Start: prev, End: deUnix, Type: "down"})
} else if prev < deUnix {
// Extend last up span to now (within heartbeat window)
if len(spans) > 0 && spans[len(spans)-1].Type == "up" {
spans[len(spans)-1].End = deUnix
}
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]any{"spans": spans, "day_start": dsUnix, "day_end": deUnix})
})
http.HandleFunc("/glass", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Page: "glass", Title: "Looking Glass — clavitor"}
data.Pops = loadPops()

View File

@ -10,7 +10,10 @@
<span id="status-text">Loading...</span>
</div>
<p class="text-sm text-tertiary" style="margin-bottom:16px">Uptime over the past 90 days.</p>
<p class="text-sm text-tertiary" style="margin-bottom:16px;display:flex;justify-content:space-between">
<span>Uptime over the past 90 days. All times UTC. Last heartbeat: <span id="last-beat">—</span></span>
<span id="utc-clock"></span>
</p>
<div id="status-nodes"></div>
@ -42,6 +45,14 @@
.st-incident { border-left:3px solid var(--border); padding:12px 16px; margin-bottom:12px; }
.st-incident-title { font-weight:600; font-size:0.9rem; }
.st-incident-meta { font-size:0.75rem; color:var(--text-tertiary); margin-top:2px; }
.st-tooltip { display:none; position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%); background:#fff; border:1px solid var(--border); border-radius:6px; padding:10px 14px; min-width:220px; box-shadow:0 4px 12px rgba(0,0,0,0.1); z-index:10; font-size:0.75rem; }
.st-tooltip-date { font-weight:600; margin-bottom:6px; }
.st-tooltip-bar { display:flex; height:8px; border-radius:2px; overflow:hidden; margin-bottom:6px; }
.st-tooltip-bar .up { background:#22c55e; }
.st-tooltip-bar .down { background:#ef4444; }
.st-tooltip-spans { color:var(--text-tertiary); line-height:1.6; }
.st-bar { position:relative; }
.st-bar:hover .st-tooltip { display:block; }
</style>
<script>
(function() {
@ -58,6 +69,10 @@
banner.style.borderColor = allOp ? '#bbf7d0' : '#fde68a';
banner.querySelector('span:first-child').innerHTML = allOp ? '&#x2714;' : '&#x26A0;';
document.getElementById('status-text').textContent = data.overall;
if (data.last_heartbeat) {
const d = new Date(data.last_heartbeat * 1000);
document.getElementById('last-beat').textContent = d.toISOString().slice(0, 19).replace('T', ' ') + ' UTC';
}
// Nodes
const nodes = data.nodes || [];
@ -90,12 +105,11 @@
const isToday = d.date === today;
let cls, h;
if (d.pct < 0) { cls = 'unknown'; h = '40%'; }
else if (isToday && n.health === 'operational') { cls = 'operational'; h = '100%'; }
else if (d.pct >= 99) { cls = 'operational'; h = '100%'; }
else if (d.pct >= 90) { cls = 'degraded'; h = '100%'; }
else if (d.pct === 0) { cls = 'unknown'; h = '40%'; }
else if (d.pct === 100) { cls = 'operational'; h = '100%'; }
else if (d.pct >= 99) { cls = 'degraded'; h = '100%'; }
else if (d.pct > 0) { cls = 'down'; h = '100%'; }
else { cls = 'down'; h = '100%'; }
html += `<div class="st-bar st-bar-${cls}" title="${d.date}: ${d.pct >= 0 ? d.pct.toFixed(1) + '%' : 'no data'}" style="height:${h}"></div>`;
html += `<div class="st-bar st-bar-${cls}" data-node="${n.id}" data-date="${d.date}" data-pct="${d.pct}" style="height:${h}" onmouseenter="showDayTooltip(this)"><div class="st-tooltip"></div></div>`;
}
html += `</div>
@ -135,8 +149,79 @@
}
}
function updateClock() {
const now = new Date();
document.getElementById('utc-clock').textContent = 'now: ' + now.toISOString().slice(0, 10) + ' ' + now.toISOString().slice(11, 19) + ' UTC';
}
updateClock();
setInterval(updateClock, 1000);
refresh();
setInterval(refresh, 30000);
// Align refresh to :00 and :30 of each minute
function scheduleRefresh() {
const now = new Date();
const s = now.getSeconds();
const next = s < 30 ? 30 - s : 60 - s;
setTimeout(() => { refresh(); scheduleRefresh(); }, next * 1000);
}
scheduleRefresh();
const spanCache = {};
window.showDayTooltip = async function(bar) {
const tip = bar.querySelector('.st-tooltip');
const node = bar.dataset.node;
const date = bar.dataset.date;
const pct = parseFloat(bar.dataset.pct);
const key = node + ':' + date;
if (pct < 0) {
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">No data</div>`;
return;
}
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">Loading...</div>`;
const today = new Date().toISOString().slice(0, 10);
if (!spanCache[key] || date === today) {
try {
const r = await fetch('/status/api/spans?node=' + encodeURIComponent(node) + '&date=' + date);
spanCache[key] = await r.json();
} catch(e) {
tip.innerHTML = `<div class="st-tooltip-date">${date}</div><div style="color:var(--text-tertiary)">Error loading</div>`;
return;
}
}
const data = spanCache[key];
const spans = data.spans || [];
const total = data.day_end - data.day_start;
if (!total || !spans.length) {
tip.innerHTML = `<div class="st-tooltip-date">${date}${pct.toFixed(1)}% uptime</div><div style="color:var(--text-tertiary)">No spans</div>`;
return;
}
// Build visual bar
let barHtml = '<div class="st-tooltip-bar">';
for (const s of spans) {
const w = ((s.end - s.start) / total * 100).toFixed(1);
barHtml += `<div class="${s.type}" style="width:${w}%"></div>`;
}
barHtml += '</div>';
// Build text list
let listHtml = '<div class="st-tooltip-spans">';
for (const s of spans) {
const start = new Date(s.start * 1000).toISOString().slice(11, 16);
const end = new Date(s.end * 1000).toISOString().slice(11, 16);
const dur = Math.round((s.end - s.start) / 60);
const icon = s.type === 'up' ? '&#9679;' : '&#9675;';
const color = s.type === 'up' ? '#16a34a' : '#dc2626';
listHtml += `<div><span style="color:${color}">${icon}</span> ${start}${end} UTC (${dur}m ${s.type})</div>`;
}
listHtml += '</div>';
tip.innerHTML = `<div class="st-tooltip-date">${date}${pct.toFixed(1)}% uptime</div>${barHtml}${listHtml}`;
};
})();
</script>
{{end}}