diff --git a/.DS_Store b/.DS_Store index a763035..1951fab 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/clavis/.DS_Store b/clavis/.DS_Store index be65361..667f9e7 100644 Binary files a/clavis/.DS_Store and b/clavis/.DS_Store differ diff --git a/clavis/clavis-vault/extension/background.js b/clavis/clavis-chrome/background.js similarity index 100% rename from clavis/clavis-vault/extension/background.js rename to clavis/clavis-chrome/background.js diff --git a/clavis/clavis-vault/extension/content.js b/clavis/clavis-chrome/content.js similarity index 100% rename from clavis/clavis-vault/extension/content.js rename to clavis/clavis-chrome/content.js diff --git a/clavis/clavis-vault/extension/icon128.png b/clavis/clavis-chrome/icon128.png similarity index 100% rename from clavis/clavis-vault/extension/icon128.png rename to clavis/clavis-chrome/icon128.png diff --git a/clavis/clavis-vault/extension/icon16.png b/clavis/clavis-chrome/icon16.png similarity index 100% rename from clavis/clavis-vault/extension/icon16.png rename to clavis/clavis-chrome/icon16.png diff --git a/clavis/clavis-vault/extension/icon48.png b/clavis/clavis-chrome/icon48.png similarity index 100% rename from clavis/clavis-vault/extension/icon48.png rename to clavis/clavis-chrome/icon48.png diff --git a/clavis/clavis-vault/extension/manifest.json b/clavis/clavis-chrome/manifest.json similarity index 100% rename from clavis/clavis-vault/extension/manifest.json rename to clavis/clavis-chrome/manifest.json diff --git a/clavis/clavis-vault/extension/popup.html b/clavis/clavis-chrome/popup.html similarity index 100% rename from clavis/clavis-vault/extension/popup.html rename to clavis/clavis-chrome/popup.html diff --git a/clavis/clavis-vault/extension/popup.js b/clavis/clavis-chrome/popup.js similarity index 100% rename from clavis/clavis-vault/extension/popup.js rename to clavis/clavis-chrome/popup.js diff --git a/clavis/clavis-vault/._.DS_Store b/clavis/clavis-vault/._.DS_Store new file mode 100644 index 0000000..28c42fb Binary files /dev/null and b/clavis/clavis-vault/._.DS_Store differ diff --git a/clavis/clavis-vault/api/handlers.go b/clavis/clavis-vault/api/handlers.go index aa96ca8..3851711 100644 --- a/clavis/clavis-vault/api/handlers.go +++ b/clavis/clavis-vault/api/handlers.go @@ -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": "", - "url": "", - "username": "", - "password": "", - "totp": "", - "notes": "", - "extra_fields": [""] -}`, 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) diff --git a/clavis/clavis-vault/api/integration_test.go b/clavis/clavis-vault/api/integration_test.go index 77660d7..d44f133 100644 --- a/clavis/clavis-vault/api/integration_test.go +++ b/clavis/clavis-vault/api/integration_test.go @@ -37,7 +37,6 @@ func newTestClient(t *testing.T) *tc { cfg := &lib.Config{ Port: "0", DataDir: tmpDir, - Mode: "self-hosted", SessionTTL: 86400, } diff --git a/clavis/clavis-vault/api/routes.go b/clavis/clavis-vault/api/routes.go index b7a6467..1808fb7 100644 --- a/clavis/clavis-vault/api/routes.go +++ b/clavis/clavis-vault/api/routes.go @@ -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) diff --git a/clavis/clavis-vault/cmd/._.DS_Store b/clavis/clavis-vault/cmd/._.DS_Store new file mode 100644 index 0000000..28c42fb Binary files /dev/null and b/clavis/clavis-vault/cmd/._.DS_Store differ diff --git a/clavis/clavis-vault/cmd/clavitor/._.DS_Store b/clavis/clavis-vault/cmd/clavitor/._.DS_Store new file mode 100644 index 0000000..28c42fb Binary files /dev/null and b/clavis/clavis-vault/cmd/clavitor/._.DS_Store differ diff --git a/clavis/clavis-vault/cmd/clavitor/main.go b/clavis/clavis-vault/cmd/clavitor/main.go index 61f81f7..54bf65d 100644 --- a/clavis/clavis-vault/cmd/clavitor/main.go +++ b/clavis/clavis-vault/cmd/clavitor/main.go @@ -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, }) diff --git a/clavis/clavis-vault/data/._.DS_Store b/clavis/clavis-vault/data/._.DS_Store new file mode 100644 index 0000000..28c42fb Binary files /dev/null and b/clavis/clavis-vault/data/._.DS_Store differ diff --git a/clavis/clavis-vault/lib/config.go b/clavis/clavis-vault/lib/config.go index c7e21f7..bb49bc2 100644 --- a/clavis/clavis-vault/lib/config.go +++ b/clavis/clavis-vault/lib/config.go @@ -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 } diff --git a/clavis/clavis-vault/lib/telemetry.go b/clavis/clavis-vault/lib/telemetry.go index ecfa97a..8443f11 100644 --- a/clavis/clavis-vault/lib/telemetry.go +++ b/clavis/clavis-vault/lib/telemetry.go @@ -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, } } diff --git a/clavis/clavis-vault/lib/telemetry_test.go b/clavis/clavis-vault/lib/telemetry_test.go index c8d66bf..db6598d 100644 --- a/clavis/clavis-vault/lib/telemetry_test.go +++ b/clavis/clavis-vault/lib/telemetry_test.go @@ -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") } diff --git a/clavitor.com/.DS_Store b/clavitor.com/.DS_Store index ede5412..3a51048 100644 Binary files a/clavitor.com/.DS_Store and b/clavitor.com/.DS_Store differ diff --git a/clavitor.com/Makefile b/clavitor.com/Makefile index 60f01b3..b62dfd1 100644 --- a/clavitor.com/Makefile +++ b/clavitor.com/Makefile @@ -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)" diff --git a/clavitor.com/clavitor-web-linux-amd64 b/clavitor.com/clavitor-web-linux-amd64 index 682fb05..68079dc 100755 Binary files a/clavitor.com/clavitor-web-linux-amd64 and b/clavitor.com/clavitor-web-linux-amd64 differ diff --git a/clavitor.com/main.go b/clavitor.com/main.go index 687778c..63d498c 100644 --- a/clavitor.com/main.go +++ b/clavitor.com/main.go @@ -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() diff --git a/clavitor.com/templates/status.tmpl b/clavitor.com/templates/status.tmpl index 111bf88..b0894c8 100644 --- a/clavitor.com/templates/status.tmpl +++ b/clavitor.com/templates/status.tmpl @@ -10,7 +10,10 @@ Loading... -

Uptime over the past 90 days.

+

+ Uptime over the past 90 days. All times UTC. Last heartbeat: + +

@@ -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; } {{end}} diff --git a/clavis/clavis-vault/scripts/release.sh b/operations/scripts/release.sh similarity index 100% rename from clavis/clavis-vault/scripts/release.sh rename to operations/scripts/release.sh