Files
Anand cafa22f714 Wire wave-2 features into navigation and live UI
- 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
2026-09-10 14:43:34 +05:30

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
}