mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-25 03:42:26 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user