Files
ferrum/internal/api/alerts.go
T
Anand 22c1dd382c Add API keys, MCP server, admin AI providers, and a built-in local LLM option
- User-scoped API keys (Profile > API Keys) for 3rd-party REST API access
  and MCP clients, each locked to one scope at creation, with expiry,
  revocation, and last-used tracking.
- A hand-rolled MCP (Model Context Protocol) server exposing the fleet
  (connections, nodes, guests, storage, pools, alerts, cluster status) as
  read tools plus one admin-gated power-action tool, so Claude Code/Desktop
  or any other MCP client can query and operate the fleet directly.
- Both the REST API and MCP are off by default and toggleable instance-wide
  from Settings > API & MCP, enforced live on every request.
- Admin-managed AI providers (any OpenAI-chat-completions-compatible
  endpoint) backing the AI Assistant's tool-calling loop, replacing the
  single hardcoded provider.
- A built-in, zero-config, no-API-key local provider backed by Needle 2
  (internal/needle) for fully offline tool-calling, wired in as a one-click
  preset. Requires the operator to separately download the Needle 2 binary
  and point FERRUM_NEEDLE_BIN at it -- Ferrum never fetches executable
  content from the network itself; see README "Built-in LLM (Needle 2)".
- System settings (CORS allow-list, instance-wide toggles) moved to the
  admin Settings UI; environment variables are now scoped to true
  bootstrap-level config only (listen address, TLS, DB connection, secret,
  optional Needle binary path).
- Fixed: node Journal tab 502'ing with "unexpected end of JSON input" on an
  empty response, and separately with a decode error on PVE versions that
  return a bare-string journal line instead of the documented {n,t} object.
- Fixed: bottom content padding disappearing on every page except the AI
  Assistant (an unconditional h-full on the content wrapper let overflowing
  content bleed through where the padding should render).
- Fixed: Profile page felt cramped despite a wide viewport (stray max-w-2xl
  cap not present on the equivalent Settings page).
- Test coverage added for the previously-untested MCP package and the new
  Needle adapter (20 new Go tests), plus a regression test for the journal
  decode fix.
2026-09-06 13:26:30 +05:30

200 lines
6.5 KiB
Go

package api
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"ferrum/internal/poller"
)
type alertRuleDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Metric string `json:"metric"`
ConnectionID *string `json:"connectionId,omitempty"`
Threshold float64 `json:"threshold"`
Severity string `json:"severity"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"createdAt"`
}
func (s *Server) listAlertRules(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.QueryContext(r.Context(), `SELECT id, name, metric, connection_id, threshold, severity, enabled, created_at FROM alert_rules ORDER BY name`)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
defer rows.Close()
out := []alertRuleDTO{}
for rows.Next() {
var ru alertRuleDTO
var enabled int
if err := rows.Scan(&ru.ID, &ru.Name, &ru.Metric, &ru.ConnectionID, &ru.Threshold, &ru.Severity, &enabled, &ru.CreatedAt); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
ru.Enabled = enabled == 1
out = append(out, ru)
}
writeJSON(w, http.StatusOK, out)
}
type createAlertRuleRequest struct {
Name string `json:"name"`
Metric string `json:"metric"`
ConnectionID *string `json:"connectionId,omitempty"`
Threshold float64 `json:"threshold"`
Severity string `json:"severity"`
}
var validMetrics = map[string]bool{
"node_cpu": true, "node_mem": true, "node_disk": true,
"guest_cpu": true, "guest_mem": true,
"storage_usage": true,
}
func (s *Server) createAlertRule(w http.ResponseWriter, r *http.Request) {
var req createAlertRuleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if req.Name == "" || !validMetrics[req.Metric] || req.Threshold <= 0 || req.Threshold > 100 {
writeErrorMsg(w, http.StatusBadRequest, "name, a valid metric, and a threshold between 0 and 100 are required")
return
}
if req.Severity != "warning" && req.Severity != "critical" {
req.Severity = "warning"
}
id := uuid.NewString()
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.ExecContext(r.Context(),
`INSERT INTO alert_rules (id, name, metric, connection_id, threshold, severity, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?)`,
id, req.Name, req.Metric, req.ConnectionID, req.Threshold, req.Severity, now,
)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "alerts.rule.create", "alerts", req.Name)
writeJSON(w, http.StatusCreated, map[string]string{"id": id})
}
func (s *Server) deleteAlertRule(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.db.ExecContext(r.Context(), `DELETE FROM alert_rules WHERE id = ?`, id); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "alerts.rule.delete", "alerts", id)
w.WriteHeader(http.StatusNoContent)
}
type alertInstanceDTO struct {
ID string `json:"id"`
RuleID string `json:"ruleId"`
ConnectionID string `json:"connectionId"`
ConnectionName string `json:"connectionName"`
ResourceName string `json:"resourceName"`
Metric string `json:"metric"`
Value float64 `json:"value"`
Threshold float64 `json:"threshold"`
Severity string `json:"severity"`
Status string `json:"status"`
TriggeredAt string `json:"triggeredAt"`
UpdatedAt string `json:"updatedAt"`
ResolvedAt *string `json:"resolvedAt,omitempty"`
}
func (s *Server) listAlerts(w http.ResponseWriter, r *http.Request) {
statusFilter := r.URL.Query().Get("status")
query := `SELECT id, rule_id, connection_id, connection_name, resource_name, metric, value, threshold, severity, status, triggered_at, updated_at, resolved_at FROM alert_instances`
args := []any{}
if statusFilter != "" {
query += ` WHERE status = ?`
args = append(args, statusFilter)
}
query += ` ORDER BY triggered_at DESC LIMIT 500`
rows, err := s.db.QueryContext(r.Context(), query, args...)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
defer rows.Close()
out := []alertInstanceDTO{}
for rows.Next() {
var a alertInstanceDTO
if err := rows.Scan(&a.ID, &a.RuleID, &a.ConnectionID, &a.ConnectionName, &a.ResourceName, &a.Metric, &a.Value, &a.Threshold, &a.Severity, &a.Status, &a.TriggeredAt, &a.UpdatedAt, &a.ResolvedAt); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) alertsSummary(w http.ResponseWriter, r *http.Request) {
rows, err := s.db.QueryContext(r.Context(), `SELECT severity, COUNT(*) FROM alert_instances WHERE status = 'active' GROUP BY severity`)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
defer rows.Close()
summary := map[string]int{"warning": 0, "critical": 0}
for rows.Next() {
var severity string
var count int
if err := rows.Scan(&severity, &count); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
summary[severity] = count
}
if err := rows.Err(); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, summary)
}
// connectionHealth reports whether each configured Proxmox connection
// answered on the alert evaluator's last poll — independent of alert_rules,
// so "is the server even reachable" always shows up regardless of whether
// the admin configured any metric threshold. Empty (not an error) when the
// evaluator hasn't ticked yet or isn't wired up.
func (s *Server) connectionHealth(w http.ResponseWriter, r *http.Request) {
if s.evaluator == nil {
writeJSON(w, http.StatusOK, []poller.ConnectionHealth{})
return
}
health, err := s.evaluator.ConnectionHealthList(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, health)
}
func (s *Server) silenceAlert(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.db.ExecContext(r.Context(), `UPDATE alert_instances SET status = 'silenced', updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "alerts.silence", "alerts", id)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}