chore: auto-commit uncommitted changes
This commit is contained in:
parent
55699985ae
commit
cb7c7c51ce
Binary file not shown.
|
Before Width: | Height: | Size: 118 B After Width: | Height: | Size: 118 B |
|
Before Width: | Height: | Size: 87 B After Width: | Height: | Size: 87 B |
|
Before Width: | Height: | Size: 100 B After Width: | Height: | Size: 100 B |
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ func newTestClient(t *testing.T) *tc {
|
|||
cfg := &lib.Config{
|
||||
Port: "0",
|
||||
DataDir: tmpDir,
|
||||
Mode: "self-hosted",
|
||||
SessionTTL: 86400,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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.
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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)"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ? '✔' : '⚠';
|
||||
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' ? '●' : '○';
|
||||
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}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue