mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-16 15:45:09 +00:00
cafa22f714
- App.tsx/AppShell/CommandPalette: register Bulk Operations and Webhooks routes + nav entries - WebhooksPage.tsx: full CRUD UI for outgoing webhook subscriptions, event-type picker, delivery log, secret-reveal-once dialog - CommandPalette: guest search now backed by GET /search once typing (ranked, cross-connection) instead of only ever filtering the locally-cached inventory fetch — fixes the DOM-mounting-at-scale concern the file already flagged in its own comments - search.go: added id to searchResult so CommandPalette's focusGuestId highlight works the same for server results as local ones - NotificationBell: subscribes to the SSE event stream (alert.triggered/resolved) to invalidate the alert queries immediately and toast new critical alerts, instead of waiting out the 30s poll
142 lines
4.2 KiB
Go
142 lines
4.2 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"ferrum/internal/pve"
|
|
)
|
|
|
|
// searchResult is one guest matched by a global cross-remote search —
|
|
// carries connection/type/node/vmid so the frontend can route straight to
|
|
// the result without a follow-up lookup.
|
|
type searchResult struct {
|
|
ID string `json:"id"` // PVE's own resource id (e.g. "qemu/100") — matches ClusterResource.ID, usable as InventoryPage's focusGuestId
|
|
ConnectionID string `json:"connectionId"`
|
|
ConnectionName string `json:"connectionName"`
|
|
Type string `json:"type"` // "qemu" | "lxc"
|
|
VMID int `json:"vmid"`
|
|
Name string `json:"name,omitempty"`
|
|
Node string `json:"node"`
|
|
Tags string `json:"tags,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
|
|
rank int // lower sorts first; internal only, not serialized
|
|
}
|
|
|
|
const searchMaxResults = 50
|
|
|
|
// globalSearch matches guest name/vmid/tags/node across every connection's
|
|
// already-fetched inventory — the same /cluster/resources call the fleet
|
|
// inventory view uses, so this is a fan-out of N *connections*, not a
|
|
// per-guest live query — GET /api/v1/search?q=.
|
|
//
|
|
// IP address is deliberately not searched: cluster/resources rows carry no
|
|
// IP field, and getting one requires a live per-guest agent call, which
|
|
// would turn this into an N-guest fan-out instead of an N-connection one.
|
|
func (s *Server) globalSearch(w http.ResponseWriter, r *http.Request) {
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
if q == "" {
|
|
writeJSON(w, http.StatusOK, []searchResult{})
|
|
return
|
|
}
|
|
|
|
rows, err := s.db.QueryContext(r.Context(), `SELECT id, name FROM connections ORDER BY name`)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
type conn struct{ ID, Name string }
|
|
var conns []conn
|
|
for rows.Next() {
|
|
var c conn
|
|
if err := rows.Scan(&c.ID, &c.Name); err != nil {
|
|
rows.Close()
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
conns = append(conns, c)
|
|
}
|
|
rows.Close()
|
|
|
|
resultsByConn := make([][]searchResult, len(conns))
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, fleetFanoutLimit)
|
|
for i, c := range conns {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(i int, c conn) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
client, err := s.clientFor(r.Context(), c.ID)
|
|
if err != nil {
|
|
return // unreachable connection: contributes no results
|
|
}
|
|
resources, err := client.ClusterResources(r.Context())
|
|
if err != nil {
|
|
return
|
|
}
|
|
resultsByConn[i] = matchResources(c.ID, c.Name, resources, q)
|
|
}(i, c)
|
|
}
|
|
wg.Wait()
|
|
|
|
var out []searchResult
|
|
for _, rs := range resultsByConn {
|
|
out = append(out, rs...)
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].rank != out[j].rank {
|
|
return out[i].rank < out[j].rank
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
if len(out) > searchMaxResults {
|
|
out = out[:searchMaxResults]
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// matchResources filters one connection's cached cluster/resources rows for
|
|
// guests matching q, ranking an exact vmid match first (rank 0), then a
|
|
// name-prefix match (rank 1), then any other substring match across
|
|
// name/tags/node/vmid (rank 2). Trivial relevance, deliberately no search
|
|
// index — the fleet is small enough that a linear scan per request is fine.
|
|
func matchResources(connID, connName string, resources []pve.ClusterResource, q string) []searchResult {
|
|
qLower := strings.ToLower(q)
|
|
var out []searchResult
|
|
for _, res := range resources {
|
|
if res.Type != "qemu" && res.Type != "lxc" {
|
|
continue
|
|
}
|
|
nameLower := strings.ToLower(res.Name)
|
|
vmidStr := strconv.Itoa(res.VMID)
|
|
|
|
rank := -1
|
|
switch {
|
|
case vmidStr == q:
|
|
rank = 0
|
|
case qLower != "" && strings.HasPrefix(nameLower, qLower):
|
|
rank = 1
|
|
case strings.Contains(nameLower, qLower),
|
|
strings.Contains(strings.ToLower(res.Tags), qLower),
|
|
strings.Contains(strings.ToLower(res.Node), qLower),
|
|
strings.Contains(vmidStr, q):
|
|
rank = 2
|
|
}
|
|
if rank < 0 {
|
|
continue
|
|
}
|
|
out = append(out, searchResult{
|
|
ID: res.ID,
|
|
ConnectionID: connID, ConnectionName: connName,
|
|
Type: res.Type, VMID: res.VMID, Name: res.Name, Node: res.Node,
|
|
Tags: res.Tags, Status: res.Status, rank: rank,
|
|
})
|
|
}
|
|
return out
|
|
}
|