diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37f99d4..ffb0711 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,7 @@ jobs: id: vars run: | echo "tag=${{ inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT" + echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" # ghcr.io requires a lowercase image name; github.repository is # already lowercase for this repo, but this keeps the workflow # correct if it's ever forked under a mixed-case owner/name. @@ -64,6 +65,10 @@ jobs: context: . platforms: linux/amd64,linux/arm64 push: true + build-args: | + VERSION=${{ steps.vars.outputs.tag }} + COMMIT=${{ github.sha }} + DATE=${{ steps.vars.outputs.date }} tags: | ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.tag }} ${{ steps.vars.outputs.image }}:latest diff --git a/Dockerfile b/Dockerfile index 8b86306..1a83f86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,11 @@ COPY . . COPY --from=web-build /web/dist ./web/dist ARG TARGETOS ARG TARGETARCH -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/ferrum ./cmd/ferrum +# Same -X stamps as scripts/build.sh; CI passes them as build args. +ARG VERSION=dev +ARG COMMIT=none +ARG DATE=unknown +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o /out/ferrum ./cmd/ferrum # "base" (not "static"): Ferrum's own binary is CGO_ENABLED=0/static and # would run fine on "static", but the bundled Needle 2 CLI (internal/needle) diff --git a/cmd/ferrum/main.go b/cmd/ferrum/main.go index d8dae53..d5e94c4 100644 --- a/cmd/ferrum/main.go +++ b/cmd/ferrum/main.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "os" "os/signal" @@ -219,14 +220,21 @@ func runServer(ctx context.Context, cfg config.Config) { } srv.SetWebFS(distFS) + // Shutdown doesn't cancel in-flight request contexts, so an open SSE + // stream or AI chat would hold it for its full budget. Cancel a shared + // base context the moment Shutdown starts so those handlers return. + baseCtx, cancelBase := context.WithCancel(context.Background()) + defer cancelBase() httpServer := &http.Server{ Addr: cfg.Server.Addr, Handler: srv.Router(), + BaseContext: func(net.Listener) context.Context { return baseCtx }, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } + httpServer.RegisterOnShutdown(cancelBase) go func() { var err error @@ -250,6 +258,7 @@ func runServer(ctx context.Context, cfg config.Config) { defer cancel() if err := httpServer.Shutdown(shutdownCtx); err != nil { slog.Error("graceful shutdown failed", "error", err) + _ = httpServer.Close() } // Stop the background loops and wait for their current iteration to diff --git a/internal/api/ai_chat.go b/internal/api/ai_chat.go index 591992f..1bac559 100644 --- a/internal/api/ai_chat.go +++ b/internal/api/ai_chat.go @@ -358,30 +358,13 @@ func (s *Server) aiChat(w http.ResponseWriter, r *http.Request) { } // A local model — or several tool round-trips against it — can easily - // exceed the 30s global request timeout applied in Router(). Detach from - // that inherited deadline (keeping request-scoped values like the - // authenticated user) and apply a generous one of our own: this is a - // long-poll-shaped endpoint by nature, not a typical CRUD call. - ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 4*time.Minute) + // exceed 30s, so Router() exempts this route from the global timeout and + // we apply a generous bound of our own. r.Context() stays the parent, so + // a client disconnect (Stop) cancels the tool loop at any point. + ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute) defer cancel() user := userFromContext(r) - // context.WithoutCancel above deliberately rides out the global 30s - // timeout, but that also threw away real client-disconnect detection — - // hitting Stop in the browser aborted the fetch, yet the tool-calling - // loop and any in-flight upstream call kept running on the server to - // completion. Watch the ORIGINAL request context ourselves and forward - // only a genuine disconnect to our own cancel: net/http cancels a - // request's context with context.Canceled when the client goes away, - // versus context.DeadlineExceeded when it's merely the 30s middleware - // timeout firing — exactly the signal we're intentionally ignoring. - go func() { - <-r.Context().Done() - if errors.Is(r.Context().Err(), context.Canceled) { - cancel() - } - }() - clearWriteDeadline(w) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") diff --git a/internal/api/connections.go b/internal/api/connections.go index 71c625c..db602e0 100644 --- a/internal/api/connections.go +++ b/internal/api/connections.go @@ -373,7 +373,7 @@ func (s *Server) testConnection(w http.ResponseWriter, r *http.Request) { } if req.Type == "pbs" { - client := pbs.New(req.Host, req.Port, pbs.WithInsecureSkipVerify(!req.VerifyTLS)) + client := pbs.New(req.Host, req.Port, pbs.WithInsecureSkipVerify(!req.VerifyTLS), pbs.WithFingerprint(req.TLSFingerprint)) if req.AuthType == "token" { client.WithAPIToken(req.TokenID, req.TokenSecret) } else if err := client.Login(r.Context(), req.Username, req.Password); err != nil { @@ -389,7 +389,7 @@ func (s *Server) testConnection(w http.ResponseWriter, r *http.Request) { return } - client := pve.New(req.Host, req.Port, pve.WithInsecureSkipVerify(!req.VerifyTLS)) + client := pve.New(req.Host, req.Port, pve.WithInsecureSkipVerify(!req.VerifyTLS), pve.WithFingerprint(req.TLSFingerprint)) if req.AuthType == "token" { client.WithAPIToken(req.TokenID, req.TokenSecret) } else if err := client.Login(r.Context(), req.Username, req.Password); err != nil { diff --git a/internal/api/console.go b/internal/api/console.go index ae346dc..0678577 100644 --- a/internal/api/console.go +++ b/internal/api/console.go @@ -2,7 +2,6 @@ package api import ( "context" - "crypto/tls" "errors" "fmt" "log/slog" @@ -236,6 +235,14 @@ var upgrader = websocket.Upgrader{ func (s *Server) consoleWebSocket(w http.ResponseWriter, r *http.Request) { sessionID := chi.URLParam(r, "sessionId") + // Claim a slot before consuming the single-use session, so a 503 at the + // cap leaves the session intact and "try again shortly" actually works. + release, ok := acquireSessionSlot(w) + if !ok { + return + } + defer release() + consoleSessionsMu.Lock() sess, ok := consoleSessions[sessionID] if ok { @@ -248,13 +255,7 @@ func (s *Server) consoleWebSocket(w http.ResponseWriter, r *http.Request) { return } - release, ok := acquireSessionSlot(w) - if !ok { - return - } - defer release() - - host, port, verifyTLS, err := s.connectionHost(r.Context(), sess.connectionID) + host, port, _, err := s.connectionHost(r.Context(), sess.connectionID) if err != nil { http.Error(w, "connection lookup failed", http.StatusBadGateway) return @@ -286,7 +287,7 @@ func (s *Server) consoleWebSocket(w http.ResponseWriter, r *http.Request) { } dialer := websocket.Dialer{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: !verifyTLS}, //nolint:gosec // per-connection trust setting, mirrors REST client + TLSClientConfig: client.WSTLSConfig(), // same trust (incl. fingerprint pin) as the REST client HandshakeTimeout: consoleDialTimeout, ReadBufferSize: 8192, WriteBufferSize: 8192, diff --git a/internal/api/nodes.go b/internal/api/nodes.go index b4c6629..92bbc76 100644 --- a/internal/api/nodes.go +++ b/internal/api/nodes.go @@ -569,6 +569,9 @@ func (s *Server) fileRestoreDownload(w http.ResponseWriter, r *http.Request) { filename = "restore" } w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + // Lift the server's 60s WriteTimeout, or a large file is silently + // truncated after the 200 has already gone out. + clearWriteDeadline(w) w.WriteHeader(http.StatusOK) _, _ = io.Copy(w, resp.Body) } @@ -887,6 +890,12 @@ func (s *Server) nodeServiceAction(w http.ResponseWriter, r *http.Request) { const uploadStorageContentLimit = 8 << 30 // 8 GiB func (s *Server) uploadStorageContent(w http.ResponseWriter, r *http.Request) { + // A multi-GB body outlasts the server's 60s Read/WriteTimeout (see + // cmd/ferrum/main.go); lift both for this request or the upload dies + // mid-stream with "i/o timeout". + rc := http.NewResponseController(w) + _ = rc.SetReadDeadline(time.Time{}) + _ = rc.SetWriteDeadline(time.Time{}) r.Body = http.MaxBytesReader(w, r.Body, uploadStorageContentLimit) if err := r.ParseMultipartForm(32 << 20); err != nil { writeErrorMsg(w, http.StatusBadRequest, "invalid upload: "+err.Error()) diff --git a/internal/api/server.go b/internal/api/server.go index 8e0eb8f..2a4d2d4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -228,7 +228,22 @@ func (s *Server) Router() http.Handler { r.Use(s.requestLogger) r.Use(s.cors) r.Use(s.securityHeaders) - r.Use(middleware.Timeout(30 * time.Second)) + // Long-lived streams are exempt. /ai/chat sets its own 4-minute bound, + // and under this timeout its context would be canceled at 30s, hiding a + // later client disconnect (Stop). /events would be cut every 30s, + // dropping any event published during the reconnect gap. Both end on + // client disconnect or server shutdown (BaseContext, cmd/ferrum/main.go). + timeout := middleware.Timeout(30 * time.Second) + r.Use(func(next http.Handler) http.Handler { + withTimeout := timeout(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/ai/chat" || r.URL.Path == "/api/v1/events" { + next.ServeHTTP(w, r) + return + } + withTimeout.ServeHTTP(w, r) + }) + }) r.Route("/api/v1", func(r chi.Router) { // Cross-origin request check — see originCheck. Mounted on the whole @@ -748,13 +763,13 @@ func (s *Server) Router() http.Handler { r.Route("/ssh", func(r chi.Router) { r.Use(s.requireAdmin) r.Post("/sessions", s.openSSHShell) + r.Delete("/known-hosts", s.forgetSSHHostKey) }) // Server-Sent Events stream of bus activity (alert // triggers/resolutions, connection health, ...) — see // internal/api/events.go. The handler blocks on r.Context().Done() - // for its lifetime; the router-wide middleware.Timeout above only - // cancels that context, it doesn't itself cut the connection. + // for its lifetime; it's exempt from the router-wide timeout. r.Get("/events", s.streamEvents) r.Route("/settings/webhooks", func(r chi.Router) { diff --git a/internal/api/ssh_console.go b/internal/api/ssh_console.go index 884acdb..6a4c8cb 100644 --- a/internal/api/ssh_console.go +++ b/internal/api/ssh_console.go @@ -9,6 +9,7 @@ import ( "net/http" "strings" "sync" + "strconv" "time" "github.com/go-chi/chi/v5" @@ -98,6 +99,28 @@ func (s *Server) verifySSHHostKey(host string, port int) ssh.HostKeyCallback { } } +// forgetSSHHostKey is DELETE /ssh/known-hosts?host=&port= — drops a pinned +// host key so a reinstalled/rekeyed host can be re-pinned on next connect. +func (s *Server) forgetSSHHostKey(w http.ResponseWriter, r *http.Request) { + host := r.URL.Query().Get("host") + port, err := strconv.Atoi(r.URL.Query().Get("port")) + if host == "" || err != nil { + writeErrorMsg(w, http.StatusBadRequest, "host and numeric port query parameters are required") + return + } + res, err := s.db.ExecContext(r.Context(), `DELETE FROM ssh_known_hosts WHERE host = ? AND port = ?`, host, port) + if err != nil { + s.writeError(w, http.StatusInternalServerError, err) + return + } + if n, _ := res.RowsAffected(); n == 0 { + writeErrorMsg(w, http.StatusNotFound, "no pinned host key for that host and port") + return + } + s.audit(r, "ssh.forget_host_key", "ssh", fmt.Sprintf("%s:%d", host, port)) + w.WriteHeader(http.StatusNoContent) +} + func sweepSSHSessionsLocked(now time.Time) { for id, sess := range sshSessions { if now.After(sess.expires.Add(consoleSessionSweepAfter)) { @@ -210,6 +233,13 @@ func newSSHSession(host string, port int, username, authType, secret string, col func (s *Server) sshWebSocket(w http.ResponseWriter, r *http.Request) { sessionID := chi.URLParam(r, "sessionId") + // Slot first: a 503 at the cap must not burn the single-use session. + release, ok := acquireSessionSlot(w) + if !ok { + return + } + defer release() + sshSessionsMu.Lock() sess, ok := sshSessions[sessionID] if ok { @@ -222,12 +252,6 @@ func (s *Server) sshWebSocket(w http.ResponseWriter, r *http.Request) { return } - release, ok := acquireSessionSlot(w) - if !ok { - return - } - defer release() - clientConn, err := upgrader.Upgrade(w, r, nil) if err != nil { return @@ -303,6 +327,7 @@ func (s *Server) sshWebSocket(w http.ResponseWriter, r *http.Request) { for { n, err := stdout.Read(buf) if n > 0 { + _ = clientConn.SetWriteDeadline(time.Now().Add(consoleWriteWait)) if werr := clientConn.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil { errc <- werr return @@ -315,6 +340,27 @@ func (s *Server) sshWebSocket(w http.ResponseWriter, r *http.Request) { } }() + // Keep-alive: same ping/read-deadline reaping as pipeWebsockets, so a + // browser that vanished (laptop sleep, NAT drop) ends the session. + _ = clientConn.SetReadDeadline(time.Now().Add(consolePongWait)) + clientConn.SetPongHandler(func(string) error { + return clientConn.SetReadDeadline(time.Now().Add(consolePongWait)) + }) + stopPing := make(chan struct{}) + defer close(stopPing) + go func() { + ticker := time.NewTicker(consolePingPeriod) + defer ticker.Stop() + for { + select { + case <-stopPing: + return + case <-ticker.C: + _ = clientConn.WriteControl(websocket.PingMessage, nil, time.Now().Add(consoleWriteWait)) + } + } + }() + // Browser -> guest, plus resize control frames. go func() { for { @@ -331,6 +377,10 @@ func (s *Server) sshWebSocket(w http.ResponseWriter, r *http.Request) { }() <-errc + // Close before Wait: an interactive shell with stdin open never exits + // on its own, so Wait alone would pin this goroutine and its slot forever. + _ = session.Close() + _ = sshConn.Close() _ = session.Wait() slog.Info("ssh session closed", "host", sess.host, "port", sess.port) } diff --git a/internal/notify/webhooks.go b/internal/notify/webhooks.go index 487b91d..036e41d 100644 --- a/internal/notify/webhooks.go +++ b/internal/notify/webhooks.go @@ -83,6 +83,10 @@ type WebhookDispatcher struct { mu sync.Mutex inFlight map[string]bool // outbox keys ("eventId|subscriptionId") currently being delivered — see dispatchOutboxRow + + // running tracks every goroutine Run spawns so Run returns only once + // they're done — shutdown then closes the DB after, not during, a write. + running sync.WaitGroup } func NewWebhookDispatcher(db *store.DB, box *secrets.Box) *WebhookDispatcher { @@ -110,10 +114,13 @@ type outboxRow struct { // Intended to be started once at boot in its own goroutine, same as // poller.AlertEvaluator.Run. func (d *WebhookDispatcher) Run(ctx context.Context, bus *events.Bus) { + defer d.running.Wait() // Queue sweeper: drains anything the previous run left behind, then // keeps re-attempting due rows. Runs on its own goroutine so a slow // receiver's retry sleeps never delay live event delivery. + d.running.Add(1) go func() { + defer d.running.Done() d.sweep(ctx) ticker := time.NewTicker(webhookSweepInterval) defer ticker.Stop() @@ -141,7 +148,11 @@ func (d *WebhookDispatcher) Run(ctx context.Context, bus *events.Bus) { // off the receive loop so one slow/unreachable webhook can't // delay delivery to the rest, or cause this subscriber's bus // buffer to fill and start dropping events. - go d.deliverToAll(ctx, evt) + d.running.Add(1) + go func() { + defer d.running.Done() + d.deliverToAll(ctx, evt) + }() } } } @@ -174,7 +185,11 @@ func (d *WebhookDispatcher) deliverToAll(ctx context.Context, evt events.Event) continue } row := outboxRow{EventID: evt.ID, SubscriptionID: sub.ID, EventType: string(evt.Type), Payload: string(body)} - go d.dispatchOutboxRow(ctx, row, sub) + d.running.Add(1) + go func() { + defer d.running.Done() + d.dispatchOutboxRow(ctx, row, sub) + }() } } diff --git a/internal/pve/client.go b/internal/pve/client.go index 083dfda..93ac516 100644 --- a/internal/pve/client.go +++ b/internal/pve/client.go @@ -207,6 +207,19 @@ func (c *Client) applyTransport() { } } +// WSTLSConfig returns the TLS config a raw WebSocket dial to this host must +// use — the same trust decision as the REST transport (pin, skip-verify, or +// system CA), so the console proxy can't bypass a configured pin. +func (c *Client) WSTLSConfig() *tls.Config { + switch { + case c.fingerprint != "": + return &tls.Config{InsecureSkipVerify: true, VerifyPeerCertificate: c.verifyFingerprint} //nolint:gosec // pin replaces CA validation + case c.skipVerify: + return &tls.Config{InsecureSkipVerify: true} //nolint:gosec // per-connection trust setting + } + return &tls.Config{} +} + // verifyFingerprint is the VerifyPeerCertificate hook for pinned clients: // it compares the SHA-256 of the leaf certificate's DER (rawCerts[0]) against // the configured fingerprint (see fingerprintMatches). certFingerprintSHA256 diff --git a/internal/pve/client_test.go b/internal/pve/client_test.go index dd94d51..067a6a4 100644 --- a/internal/pve/client_test.go +++ b/internal/pve/client_test.go @@ -121,4 +121,16 @@ func TestFingerprintPinning(t *testing.T) { if _, err := none.Version(context.Background()); err == nil { t.Fatal("expected self-signed cert to fail verification when no pin and no skip-verify") } + + // The console's raw WebSocket dial must enforce the same pin. + raw := srv.Certificate().Raw + if err := c.WSTLSConfig().VerifyPeerCertificate([][]byte{raw}, nil); err != nil { + t.Errorf("WSTLSConfig rejected the pinned cert: %v", err) + } + if err := wrong.WSTLSConfig().VerifyPeerCertificate([][]byte{raw}, nil); err == nil { + t.Error("WSTLSConfig accepted a cert that doesn't match the pin") + } + if cfg := none.WSTLSConfig(); cfg.InsecureSkipVerify { + t.Error("WSTLSConfig skips verification with no pin and no skip-verify") + } } diff --git a/internal/store/db.go b/internal/store/db.go index 40617fb..420d313 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -29,11 +29,10 @@ func (d *DB) rebind(query string) string { // rebindPostgres rewrites "?" placeholders to Postgres's "$1, $2, ..." style, // skipping any "?" inside a single-quoted SQL string literal (tracking '' -// as an escaped quote, not a close) so a query with a literal "?" in a LIKE -// pattern or a Postgres JSONB "?"/"?|"/"?&" operator isn't corrupted into a -// bogus positional parameter — every query in this codebase currently only -// uses "?" as a placeholder, but this makes that an enforced invariant -// instead of a silent assumption the moment one doesn't. +// as an escaped quote, not a close) so a literal "?" in a LIKE pattern +// survives. It does NOT protect a bare JSONB "?"/"?|"/"?&" operator (those +// sit outside quotes and get renumbered) and doesn't understand "--" +// comments, so neither may appear in a query. func rebindPostgres(query string) string { var b strings.Builder n := 0 diff --git a/web/src/components/charts/ResourceAreaChart.tsx b/web/src/components/charts/ResourceAreaChart.tsx index 50e60fd..f8be1df 100644 --- a/web/src/components/charts/ResourceAreaChart.tsx +++ b/web/src/components/charts/ResourceAreaChart.tsx @@ -1,3 +1,4 @@ +import { useId } from "react" import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts" import { byteUnitIndex, formatBytesAtUnit, formatRRDTick, formatRRDTooltip } from "@/lib/utils" import { computeNiceScale } from "@/lib/niceScale" @@ -91,6 +92,9 @@ export function ResourceAreaChart({ allowDecimals = true, valueKind, }: ResourceAreaChartProps) { + // Per-instance gradient ids: two charts plotting the same series key would + // otherwise emit duplicate ids and both paint with the first one's . + const gradId = useId().replace(/[^a-z0-9]/gi, "") // X tick granularity follows the visible span: minutes for an hour view, // day-hours for a week, dates for a year. const times = data.map((d) => d.time as number).filter((t) => typeof t === "number" && t > 0) @@ -141,7 +145,7 @@ export function ResourceAreaChart({ {series.map((s) => ( - + @@ -189,7 +193,7 @@ export function ResourceAreaChart({ dataKey={s.key} name={s.label} stroke={s.color} - fill={`url(#grad-${s.key})`} + fill={`url(#grad-${gradId}-${s.key})`} strokeWidth={1.75} connectNulls dot={false} diff --git a/web/src/components/inventory/GuestDetailDialog.tsx b/web/src/components/inventory/GuestDetailDialog.tsx index 6d34fba..110ef9b 100644 --- a/web/src/components/inventory/GuestDetailDialog.tsx +++ b/web/src/components/inventory/GuestDetailDialog.tsx @@ -57,6 +57,8 @@ export function GuestDetailDialog({ connId, guest, onOpenChange }: GuestDetailDi const queryClient = useQueryClient() const confirm = useConfirm() const base = guest ? `/connections/${connId}/guests/${guest.type}/${guest.node}/${guest.vmid}` : "" + // guest.id ("qemu/100") repeats across connections — per-guest state keys on both. + const guestKey = guest ? `${connId}/${guest.id}` : undefined const configQuery = useQuery({ queryKey: ["guest-config", connId, guest?.id], @@ -90,8 +92,10 @@ export function GuestDetailDialog({ connId, guest, onOpenChange }: GuestDetailDi const [editingConfig, setEditingConfig] = useState(false) const [configForm, setConfigForm] = useState({ cores: "", memory: "", tags: "", notes: "" }) + // Not while editing: a refetch (another tab's save, a DNS/hardware save + // here) would otherwise wipe fields the user is halfway through typing. useEffect(() => { - if (configQuery.data) { + if (configQuery.data && !editingConfig) { setConfigForm({ cores: configQuery.data.cores?.toString() ?? "", memory: configQuery.data.memory?.toString() ?? "", @@ -100,7 +104,7 @@ export function GuestDetailDialog({ connId, guest, onOpenChange }: GuestDetailDi }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [configQuery.data]) + }, [configQuery.data, editingConfig]) const updateConfig = useMutation({ mutationFn: () => @@ -129,21 +133,26 @@ export function GuestDetailDialog({ connId, guest, onOpenChange }: GuestDetailDi onSuccess: () => { toast.success("Configuration updated") setEditingKey(null) + dnsSyncedFor.current = undefined // pick up the saved values from the refetch queryClient.invalidateQueries({ queryKey: ["guest-config", connId, guest?.id] }) }, onError: (err) => toast.error(err instanceof ApiError ? err.message : "Failed to update configuration"), }) const [dns, setDns] = useState({ nameserver: "", searchdomain: "" }) + // Sync once per guest (and after a save), not on every refetch — same + // reason as NodeSystemPanel: a background refetch mustn't clobber typing. + const dnsSyncedFor = useRef(undefined) useEffect(() => { - if (configQuery.data) { + if (configQuery.data && dnsSyncedFor.current !== guestKey) { + dnsSyncedFor.current = guestKey setDns({ nameserver: (configQuery.data.raw?.nameserver as string | undefined) ?? "", searchdomain: (configQuery.data.raw?.searchdomain as string | undefined) ?? "", }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [configQuery.data]) + }, [configQuery.data, guestKey]) const [resizeDisk, setResizeDisk] = useState("") const [resizeAmount, setResizeAmount] = useState("") @@ -191,12 +200,12 @@ export function GuestDetailDialog({ connId, guest, onOpenChange }: GuestDetailDi // when the dialog switches to guest B must not let its result land in // execPid after the switch, or the dialog starts polling B's agent for a // pid that belongs to A's process table. - const currentGuestIdRef = useRef(guest?.id) - currentGuestIdRef.current = guest?.id + const currentGuestKeyRef = useRef(guestKey) + currentGuestKeyRef.current = guestKey const execMutation = useMutation({ mutationFn: (forGuestId: string | undefined) => api.post(`${base}/agent/exec`, { command: ["/bin/sh", "-c", execCommand] }).then((res) => ({ res, forGuestId })), onSuccess: ({ res, forGuestId }) => { - if (forGuestId === currentGuestIdRef.current) setExecPid(res.pid) + if (forGuestId === currentGuestKeyRef.current) setExecPid(res.pid) }, onError: (err) => toast.error(err instanceof ApiError ? err.message : "Exec failed — is the guest agent running?"), }) @@ -330,7 +339,7 @@ export function GuestDetailDialog({ connId, guest, onOpenChange }: GuestDetailDi refetchInterval: 5_000, }) const liveStatus = liveStatusQuery.data - const liveRates = useLiveRates(liveStatus) + const liveRates = useLiveRates(liveStatus, guestKey) const paused = liveStatus?.qmpstatus === "paused" const metricSpecs: SeriesSpec[] = useMemo( @@ -522,8 +531,10 @@ export function GuestDetailDialog({ connId, guest, onOpenChange }: GuestDetailDi setSnapName("") setCloneNewId("") setMigrateTarget("") + setEditingConfig(false) + setEditingKey(null) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [guest?.id]) + }, [guestKey]) if (!guest) return null @@ -1033,7 +1044,7 @@ export function GuestDetailDialog({ connId, guest, onOpenChange }: GuestDetailDi onChange={(e) => setExecCommand(e.target.value)} className="font-mono text-xs" /> - diff --git a/web/src/components/profile/McpIntegrationCard.tsx b/web/src/components/profile/McpIntegrationCard.tsx index ee6d9ab..2e58950 100644 --- a/web/src/components/profile/McpIntegrationCard.tsx +++ b/web/src/components/profile/McpIntegrationCard.tsx @@ -1,19 +1,18 @@ import { useQuery } from "@tanstack/react-query" import { Check, Copy, Plug, ShieldOff } from "lucide-react" -import { useState } from "react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { api } from "@/lib/api" +import { useCopiedFlag } from "@/lib/useCopiedFlag" function CopyBlock({ text }: { text: string }) { - const [copied, setCopied] = useState(false) + const [copied, flashCopied] = useCopiedFlag(2000) function copy() { navigator.clipboard.writeText(text).then(() => { - setCopied(true) + flashCopied() toast.success("Copied to clipboard") - setTimeout(() => setCopied(false), 2000) }).catch(() => toast.error("Could not copy to clipboard")) } return ( diff --git a/web/src/components/system/NodeSystemPanel.tsx b/web/src/components/system/NodeSystemPanel.tsx index 17beeed..f63f69d 100644 --- a/web/src/components/system/NodeSystemPanel.tsx +++ b/web/src/components/system/NodeSystemPanel.tsx @@ -79,17 +79,23 @@ export function NodeSystemPanel({ connId, node }: NodeSystemPanelProps) { // --- Hosts --- const hostsQuery = useQuery({ queryKey: ["node-hosts", connId, node], queryFn: () => api.get(`${base}/hosts`) }) const [hostsData, setHostsData] = useState("") + // The digest the form's text was loaded with — sending the latest polled + // one instead would defeat PVE's conflict check and overwrite another + // admin's edit with our stale text. + const [hostsDigest, setHostsDigest] = useState() const hostsSyncedFor = useRef(null) useEffect(() => { if (hostsQuery.data && hostsSyncedFor.current !== nodeKey) { setHostsData(hostsQuery.data.data) + setHostsDigest(hostsQuery.data.digest) hostsSyncedFor.current = nodeKey } }, [hostsQuery.data, nodeKey]) const saveHosts = useMutation({ - mutationFn: () => api.put(`${base}/hosts`, { data: hostsData, digest: hostsQuery.data?.digest }), + mutationFn: () => api.put(`${base}/hosts`, { data: hostsData, digest: hostsDigest }), onSuccess: () => { toast.success("/etc/hosts updated") + hostsSyncedFor.current = null // re-sync text + digest from the refetch below queryClient.invalidateQueries({ queryKey: ["node-hosts", connId, node] }) }, onError: (err) => toast.error(err instanceof ApiError ? err.message : "Failed to update hosts file"), diff --git a/web/src/lib/useAIConversations.ts b/web/src/lib/useAIConversations.ts index ecca4ee..33a2163 100644 --- a/web/src/lib/useAIConversations.ts +++ b/web/src/lib/useAIConversations.ts @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useState } from "react" +import { useCallback, useRef, useState } from "react" import { toast } from "sonner" import type { AIChatMessage } from "@/lib/api" +import { useAuth } from "@/lib/auth" /** One completed tool call, kept alongside the assistant message it belongs * to so the evidence for an answer (what was checked, and what it returned) @@ -60,7 +61,10 @@ export interface Conversation { updatedAt: string } -const STORAGE_KEY = "ferrum.ai-assistant.conversations.v1" +// Per-user key: history (including tool results about the infrastructure) +// must not be shown to the next person who signs in on a shared browser. +const LEGACY_STORAGE_KEY = "ferrum.ai-assistant.conversations.v1" +const storageKey = (userId: string) => `${LEGACY_STORAGE_KEY}.${userId}` const MAX_CONVERSATIONS = 50 // A long-running chat (lots of tool-call evidence attached to each answer) // keeps growing forever otherwise — cap what's persisted per conversation so @@ -72,9 +76,16 @@ function newId() { return Math.random().toString(36).slice(2) + Date.now().toString(36) } -function load(): Conversation[] { +function load(key: string): Conversation[] { try { - const raw = localStorage.getItem(STORAGE_KEY) + // One-time migration of the old unscoped key to whoever opens the + // assistant first after upgrading, then it's gone for everyone else. + const legacy = localStorage.getItem(LEGACY_STORAGE_KEY) + if (legacy !== null) { + if (localStorage.getItem(key) === null) localStorage.setItem(key, legacy) + localStorage.removeItem(LEGACY_STORAGE_KEY) + } + const raw = localStorage.getItem(key) if (!raw) return [] const parsed = JSON.parse(raw) return Array.isArray(parsed) ? parsed : [] @@ -85,14 +96,14 @@ function load(): Conversation[] { let warnedAboutSaveFailure = false -function save(conversations: Conversation[]) { +function save(key: string, conversations: Conversation[]) { try { const trimmed = conversations.slice(0, MAX_CONVERSATIONS).map((c) => c.messages.length > MAX_MESSAGES_PER_CONVERSATION ? { ...c, messages: c.messages.slice(-MAX_MESSAGES_PER_CONVERSATION) } : c, ) - localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)) + localStorage.setItem(key, JSON.stringify(trimmed)) } catch { // Private browsing / storage quota — the chat still works for this tab, // it just won't survive a reload. Once per session is enough to tell the @@ -120,10 +131,23 @@ function titleFrom(text: string): string { * (there's nothing here an admin or another device needs to see). */ export function useAIConversations(defaultModelId: string) { - const [conversations, setConversations] = useState(load) - const [activeId, setActiveId] = useState(() => load()[0]?.id ?? null) + const { user } = useAuth() + const key = storageKey(user?.id ?? "anonymous") + const [conversations, setConversationsState] = useState(() => load(key)) + const [activeId, setActiveId] = useState(() => conversations[0]?.id ?? null) - useEffect(() => save(conversations), [conversations]) + // Writes go through a ref and hit localStorage synchronously, not via an + // effect: a stream that finishes (or is aborted) after the page unmounted + // still persists its partial answer — a setState then would be dropped. + const convRef = useRef(conversations) + const setConversations = useCallback( + (update: (prev: Conversation[]) => Conversation[]) => { + convRef.current = update(convRef.current) + save(key, convRef.current) + setConversationsState(convRef.current) + }, + [key], + ) const active = conversations.find((c) => c.id === activeId) ?? null @@ -134,7 +158,7 @@ export function useAIConversations(defaultModelId: string) { setActiveId(conv.id) return conv.id }, - [defaultModelId], + [defaultModelId, setConversations], ) const deleteConversation = useCallback( @@ -149,7 +173,7 @@ export function useAIConversations(defaultModelId: string) { return remaining }) }, - [], + [setConversations], ) /** Bulk counterpart to deleteConversation — for the sidebar's multi-select @@ -162,7 +186,7 @@ export function useAIConversations(defaultModelId: string) { setActiveId((cur) => (cur && doomed.has(cur) ? (remaining[0]?.id ?? null) : cur)) return remaining }) - }, []) + }, [setConversations]) const updateConversation = useCallback((id: string, patch: Partial>) => { setConversations((prev) => @@ -171,16 +195,16 @@ export function useAIConversations(defaultModelId: string) { // Most-recently-updated first, like every chat product's sidebar. .sort((a, b) => (a.id === id ? -1 : b.id === id ? 1 : 0)), ) - }, []) + }, [setConversations]) const setMessages = useCallback( (id: string, messages: DisplayMessage[]) => { const firstUser = messages.find((m) => m.role === "user") - const conv = conversations.find((c) => c.id === id) + const conv = convRef.current.find((c) => c.id === id) const title = conv && conv.title !== "New chat" ? conv.title : firstUser ? titleFrom(firstUser.content) : "New chat" updateConversation(id, { messages, title }) }, - [conversations, updateConversation], + [updateConversation], ) return { conversations, active, activeId, setActiveId, createConversation, deleteConversation, deleteConversations, updateConversation, setMessages } diff --git a/web/src/lib/useLiveRates.ts b/web/src/lib/useLiveRates.ts index ef44ba9..6a0bfc9 100644 --- a/web/src/lib/useLiveRates.ts +++ b/web/src/lib/useLiveRates.ts @@ -15,8 +15,10 @@ export interface LiveRates { * previous one. If the counter goes backwards (guest restarted) the rate is * skipped for one sample instead of reporting nonsense. */ -export function useLiveRates(status: GuestLiveStatus | undefined): LiveRates { - const prev = useRef<{ t: number; netin: number; netout: number; diskread: number; diskwrite: number } | null>(null) +export function useLiveRates(status: GuestLiveStatus | undefined, guestKey?: string): LiveRates { + // guestKey: the previous sample must be the same guest's, or the first rate + // after switching guests is B's counters minus A's. + const prev = useRef<{ key?: string; t: number; netin: number; netout: number; diskread: number; diskwrite: number } | null>(null) const [rates, setRates] = useState({}) useEffect(() => { @@ -25,7 +27,11 @@ export function useLiveRates(status: GuestLiveStatus | undefined): LiveRates { return } const now = Date.now() / 1000 - const p = prev.current + let p = prev.current + if (p && p.key !== guestKey) { + p = null + setRates({}) + } const cur = { netin: status.netin ?? 0, netout: status.netout ?? 0, @@ -42,8 +48,8 @@ export function useLiveRates(status: GuestLiveStatus | undefined): LiveRates { diskwrite: rate(cur.diskwrite, p.diskwrite), }) } - prev.current = { t: now, ...cur } - }, [status]) + prev.current = { key: guestKey, t: now, ...cur } + }, [status, guestKey]) if (!status || status.status !== "running") { return {} diff --git a/web/src/pages/AIAssistantPage.tsx b/web/src/pages/AIAssistantPage.tsx index abc98f7..03c1978 100644 --- a/web/src/pages/AIAssistantPage.tsx +++ b/web/src/pages/AIAssistantPage.tsx @@ -326,8 +326,10 @@ export function AIAssistantPage() { if (!el) return if (stickToBottomRef.current) el.scrollTop = el.scrollHeight else setNewBelow(true) + // Live-stream deps only count when the stream belongs to this transcript; + // another conversation's tokens must not raise "new messages below" here. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [displayMessages.length, streamingText, streamingReasoning, toolActivity.length]) + }, [displayMessages.length, isStreamingHere && streamingText, isStreamingHere && streamingReasoning, isStreamingHere && toolActivity.length]) // Leaving the page mid-stream must abort the in-flight chat request — the // SSE reader would otherwise keep pulling tokens for a page that no longer @@ -431,7 +433,11 @@ export function AIAssistantPage() { setToolActivity((prev) => { const idx = id ? prev.findIndex((t) => t.id === id) - : [...prev].reverse().findIndex((t) => t.name === name && t.status === "running") + : (() => { + // Search newest-first, then map back to a real index. + const fromEnd = [...prev].reverse().findIndex((t) => t.name === name && t.status === "running") + return fromEnd === -1 ? -1 : prev.length - 1 - fromEnd + })() if (idx === -1) return prev const next = [...prev] next[idx] = { ...next[idx], status: ok ? "ok" : "error", result } @@ -762,7 +768,7 @@ export function AIAssistantPage() { happening" cue than the per-message ThinkingDots/spinners alone, visible even while scrolled away from the bottom of a long reply. */}
- {streaming && ( + {isStreamingHere && (
@@ -978,12 +984,19 @@ export function AIAssistantPage() { rows={1} className="max-h-32 flex-1 resize-none bg-transparent py-1.5 text-sm outline-none" /> - {streaming ? ( + {isStreamingHere ? ( ) : ( - )} diff --git a/web/src/pages/ClusterPage.tsx b/web/src/pages/ClusterPage.tsx index 6969115..f725653 100644 --- a/web/src/pages/ClusterPage.tsx +++ b/web/src/pages/ClusterPage.tsx @@ -102,13 +102,14 @@ export function ClusterPage() { Access - + {/* Keyed per connection so selections/forms never carry over to another cluster. */} + - + - + )} @@ -128,7 +129,7 @@ function SDNPanel({ base, connId }: { base: string; connId: string }) { const [selectedVnet, setSelectedVnet] = useState(null) const subnetsQuery = useQuery({ queryKey: ["sdn-subnets", connId, selectedVnet], - queryFn: () => api.get(`${base}/cluster/sdn/vnets/${selectedVnet}/subnets`), + queryFn: () => api.get(`${base}/cluster/sdn/vnets/${encodeURIComponent(selectedVnet!)}/subnets`), enabled: !!selectedVnet, }) @@ -186,7 +187,7 @@ function SDNPanel({ base, connId }: { base: string; connId: string }) { const [subnetForm, setSubnetForm] = useState({ cidr: "", gateway: "" }) const createSubnet = useMutation({ - mutationFn: () => api.post(`${base}/cluster/sdn/vnets/${selectedVnet}/subnets`, { cidr: subnetForm.cidr, gateway: subnetForm.gateway || undefined }), + mutationFn: () => api.post(`${base}/cluster/sdn/vnets/${encodeURIComponent(selectedVnet!)}/subnets`, { cidr: subnetForm.cidr, gateway: subnetForm.gateway || undefined }), onSuccess: () => { toast.success("Subnet created — Apply to activate it") setSubnetForm({ cidr: "", gateway: "" }) @@ -195,7 +196,7 @@ function SDNPanel({ base, connId }: { base: string; connId: string }) { onError: (err) => toast.error(err instanceof ApiError ? err.message : "Failed to create subnet"), }) const deleteSubnet = useMutation({ - mutationFn: (subnet: string) => api.delete(`${base}/cluster/sdn/vnets/${selectedVnet}/subnets?subnet=${encodeURIComponent(subnet)}`), + mutationFn: (subnet: string) => api.delete(`${base}/cluster/sdn/vnets/${encodeURIComponent(selectedVnet!)}/subnets?subnet=${encodeURIComponent(subnet)}`), onSuccess: () => { toast.success("Subnet deleted") queryClient.invalidateQueries({ queryKey: ["sdn-subnets", connId, selectedVnet] }) diff --git a/web/src/pages/ConsolePage.tsx b/web/src/pages/ConsolePage.tsx index c017a3a..282f45c 100644 --- a/web/src/pages/ConsolePage.tsx +++ b/web/src/pages/ConsolePage.tsx @@ -369,8 +369,9 @@ function ShellTerminal({ } }) socket.addEventListener("message", (ev) => { - const data = ev.data instanceof ArrayBuffer ? new TextDecoder().decode(ev.data) : String(ev.data) - term.write(data) + // Raw bytes, not a per-frame TextDecoder: xterm keeps its own UTF-8 + // decoder state, so a multi-byte char split across frames stays intact. + term.write(ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : String(ev.data)) }) term.onData((data) => { if (socket?.readyState === WebSocket.OPEN) socket.send(data) diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 8031ac5..164fdd5 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -158,6 +158,11 @@ export function DashboardPage() { queryKey: ["dashboard", activeId], queryFn: () => (activeId ? api.get(`/dashboards/${activeId}`) : Promise.resolve(null)), enabled: Boolean(activeId), + // This page is the only writer of the layout, and the render-time sync + // below replaces local state on every new fetch — a background refetch + // would snap an unsaved drag back to the server copy. + refetchInterval: false, + refetchOnWindowFocus: false, }) // Synchronize local editable layout with loaded dashboard when activeId or query changes @@ -184,6 +189,13 @@ export function DashboardPage() { return () => window.removeEventListener("beforeunload", onBeforeUnload) }, []) + // The catch below runs after a later render; it must compare against the + // dashboard on screen THEN, not the activeId captured when the save began. + const activeIdRef = useRef(activeId) + useEffect(() => { + activeIdRef.current = activeId + }, [activeId]) + function saveLayout(next: WidgetSpec[], targetId = activeId) { if (!targetId) return dirty.current = false @@ -196,9 +208,10 @@ export function DashboardPage() { // changes" warning while doing nothing for the edit that actually // failed (which has no retry path once its own widgets are gone // from state — the toast is the only signal left for that case). - if (targetId === activeId) dirty.current = true + const stillOnScreen = targetId === activeIdRef.current + if (stillOnScreen) dirty.current = true const msg = err instanceof ApiError ? err.message : "Failed to save dashboard layout" - toast.error(targetId === activeId ? `${msg} — your changes are not saved yet.` : `${msg} — changes to the dashboard you left were not saved.`) + toast.error(stillOnScreen ? `${msg} — your changes are not saved yet.` : `${msg} — changes to the dashboard you left were not saved.`) }) } diff --git a/web/src/pages/PBSPage.tsx b/web/src/pages/PBSPage.tsx index f1a5f4c..303a33e 100644 --- a/web/src/pages/PBSPage.tsx +++ b/web/src/pages/PBSPage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import type { ColumnDef } from "@tanstack/react-table" import { DatabaseBackup, Play, ScrollText } from "lucide-react" -import { useEffect, useMemo, useRef, useState } from "react" +import { type RefObject, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -134,7 +134,7 @@ export function PBSPage() { ) : (
{(storesQuery.data ?? []).map((ds) => ( - + ))}
)} @@ -156,6 +156,9 @@ function DatastoreCard({ connId, ds, isAdmin }: { connId: string; ds: PBSDatasto // DatastoreCard stays mounted for as long as the tabs it hosts do. const [gcUpid, setGcUpid] = useState(null) const [logUpid, setLogUpid] = useState(null) + // Also lifted: the last UPID whose finish was announced, so remounting the + // Maintenance tab doesn't re-toast (and re-invalidate) a finished GC. + const gcSettledRef = useRef(null) return ( @@ -206,6 +209,7 @@ function DatastoreCard({ connId, ds, isAdmin }: { connId: string; ds: PBSDatasto isAdmin={isAdmin} gcUpid={gcUpid} setGcUpid={setGcUpid} + settledRef={gcSettledRef} logUpid={logUpid} setLogUpid={setLogUpid} /> @@ -400,6 +404,7 @@ function MaintenanceTab({ isAdmin, gcUpid, setGcUpid, + settledRef, logUpid, setLogUpid, }: { @@ -412,6 +417,7 @@ function MaintenanceTab({ // Lifted into DatastoreCard so it survives this tab unmounting/remounting. gcUpid: string | null setGcUpid: (upid: string | null) => void + settledRef: RefObject logUpid: string | null setLogUpid: (upid: string | null) => void }) { @@ -428,7 +434,6 @@ function MaintenanceTab({ // The task status carries no end-time, so "finished" is exactly the first // stopped poll — toast it once per UPID, then refresh the listing so the // new removed/pending figures actually show up. - const settledRef = useRef(null) useEffect(() => { const t = taskQuery.data if (!t || t.status !== "stopped" || settledRef.current === t.upid) return @@ -436,7 +441,7 @@ function MaintenanceTab({ if (t.exitstatus && t.exitstatus !== "OK") toast.error(`Garbage collection on ${store} failed: ${t.exitstatus}`) else toast.success(`Garbage collection on ${store} finished`) queryClient.invalidateQueries({ queryKey: ["pbs-datastores", connId] }) - }, [taskQuery.data, connId, store, queryClient]) + }, [taskQuery.data, connId, store, queryClient, settledRef]) const startGc = useMutation({ mutationFn: () => api.post<{ upid: string }>(`/connections/${connId}/pbs/datastores/${encodeURIComponent(store)}/gc`),