Third audit pass: fix console leaks, TLS pinning, streaming timeouts, and form state bugs

Backend:
- SSH console: close the session on browser disconnect (Wait() blocked
  forever, leaking the 50-slot console cap); add ping/read deadline
- VNC console dials PVE with the connection's pinned TLS fingerprint;
  test-connection honors the pin too
- Claim a console slot before consuming the single-use session
- Exempt /ai/chat and /events from the 30s router timeout so Stop cancels
  the AI tool loop and the event stream isn't cut every 30s
- Lift server Read/WriteTimeout for ISO uploads and file-restore downloads
- Shutdown: webhook dispatcher waits for its goroutines; cancel request
  contexts via BaseContext; force Close on Shutdown timeout
- Admin endpoint DELETE /ssh/known-hosts to clear a pinned host key
- Stamp version/commit/date into Docker images; fix rebind comment

Frontend:
- Dashboard: stop background refetch from reverting unsaved drags; fix
  save-failure check comparing activeId with itself
- AI assistant: fix reversed-index tool-result matching; scope Stop,
  progress bar and auto-scroll to the streaming conversation; persist
  partial answers after unmount; per-user chat history storage
- Hosts file saves with the digest it was loaded with
- Guest dialog: don't clobber config/DNS edits on refetch; key state by
  connection + guest
- PBS GC settled state survives tab remount; cards keyed per connection
- Cluster panels reset on connection switch; encode vnet names
- Shell console UTF-8 split across frames; live rates reset per guest;
  per-instance chart gradient ids; McpIntegrationCard uses useCopiedFlag
This commit is contained in:
Anand
2026-09-24 23:22:53 +05:30
parent bb99771f20
commit eb8e5d5074
24 changed files with 303 additions and 105 deletions
+5
View File
@@ -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
+5 -1
View File
@@ -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)
+9
View File
@@ -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
+4 -21
View File
@@ -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")
+2 -2
View File
@@ -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 {
+10 -9
View File
@@ -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,
+9
View File
@@ -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())
+18 -3
View File
@@ -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) {
+56 -6
View File
@@ -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)
}
+17 -2
View File
@@ -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)
}()
}
}
+13
View File
@@ -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
+12
View File
@@ -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")
}
}
+4 -5
View File
@@ -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
@@ -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 <defs>.
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({
<AreaChart data={data} margin={{ top: 14, right: 14, left: 0, bottom: 4 }} syncId={syncId}>
<defs>
{series.map((s) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<linearGradient key={s.key} id={`grad-${gradId}-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={s.color} stopOpacity={0.3} />
<stop offset="95%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
@@ -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}
@@ -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<string | undefined>(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<GuestAgentExecResult>(`${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"
/>
<Button size="sm" disabled={!execCommand || execMutation.isPending} onClick={() => execMutation.mutate(guest?.id)}>
<Button size="sm" disabled={!execCommand || execMutation.isPending} onClick={() => execMutation.mutate(guestKey)}>
<Terminal className="h-3.5 w-3.5" /> Run
</Button>
</div>
@@ -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 (
@@ -79,17 +79,23 @@ export function NodeSystemPanel({ connId, node }: NodeSystemPanelProps) {
// --- Hosts ---
const hostsQuery = useQuery({ queryKey: ["node-hosts", connId, node], queryFn: () => api.get<NodeHosts>(`${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<string | undefined>()
const hostsSyncedFor = useRef<string | null>(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"),
+39 -15
View File
@@ -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<Conversation[]>(load)
const [activeId, setActiveId] = useState<string | null>(() => load()[0]?.id ?? null)
const { user } = useAuth()
const key = storageKey(user?.id ?? "anonymous")
const [conversations, setConversationsState] = useState<Conversation[]>(() => load(key))
const [activeId, setActiveId] = useState<string | null>(() => 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<Pick<Conversation, "title" | "modelId" | "reasoningEffort" | "messages">>) => {
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 }
+11 -5
View File
@@ -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<LiveRates>({})
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 {}
+18 -5
View File
@@ -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. */}
<div className="relative h-0.5 shrink-0 overflow-hidden bg-transparent">
{streaming && (
{isStreamingHere && (
<div className="absolute inset-0 bg-[var(--bg-muted)]">
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-brand-500 to-transparent" />
</div>
@@ -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 ? (
<Button variant="secondary" className="shrink-0" size="icon" onClick={stop} title="Stop generating" aria-label="Stop generating">
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button className="shrink-0" size="icon" onClick={() => send()} disabled={!input.trim()} title="Send" aria-label="Send message">
<Button
className="shrink-0"
size="icon"
onClick={() => send()}
disabled={!input.trim() || streaming}
title={streaming ? "Another conversation is still answering" : "Send"}
aria-label="Send message"
>
<Send className="h-3.5 w-3.5" />
</Button>
)}
+7 -6
View File
@@ -102,13 +102,14 @@ export function ClusterPage() {
<TabsTrigger value="access">Access</TabsTrigger>
</TabsList>
<TabsContent value="sdn">
<SDNPanel base={base} connId={activeConnId} />
{/* Keyed per connection so selections/forms never carry over to another cluster. */}
<SDNPanel key={activeConnId} base={base} connId={activeConnId} />
</TabsContent>
<TabsContent value="nodes">
<ClusterNodesPanel base={base} connId={activeConnId} />
<ClusterNodesPanel key={activeConnId} base={base} connId={activeConnId} />
</TabsContent>
<TabsContent value="access">
<AccessPanel base={base} connId={activeConnId} />
<AccessPanel key={activeConnId} base={base} connId={activeConnId} />
</TabsContent>
</Tabs>
)}
@@ -128,7 +129,7 @@ function SDNPanel({ base, connId }: { base: string; connId: string }) {
const [selectedVnet, setSelectedVnet] = useState<string | null>(null)
const subnetsQuery = useQuery({
queryKey: ["sdn-subnets", connId, selectedVnet],
queryFn: () => api.get<SDNSubnet[]>(`${base}/cluster/sdn/vnets/${selectedVnet}/subnets`),
queryFn: () => api.get<SDNSubnet[]>(`${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] })
+3 -2
View File
@@ -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)
+15 -2
View File
@@ -158,6 +158,11 @@ export function DashboardPage() {
queryKey: ["dashboard", activeId],
queryFn: () => (activeId ? api.get<DashboardFull>(`/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.`)
})
}
+9 -4
View File
@@ -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() {
) : (
<div className="space-y-3">
{(storesQuery.data ?? []).map((ds) => (
<DatastoreCard key={ds.store} connId={activeConnId} ds={ds} isAdmin={isAdmin} />
<DatastoreCard key={`${activeConnId}/${ds.store}`} connId={activeConnId} ds={ds} isAdmin={isAdmin} />
))}
</div>
)}
@@ -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<string | null>(null)
const [logUpid, setLogUpid] = useState<string | null>(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<string | null>(null)
return (
<Card>
@@ -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<string | null>
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<string | null>(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`),