diff --git a/internal/api/api.go b/internal/api/api.go index 16a6e0e..a3835ae 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -51,6 +51,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/targets", s.listTargets) mux.HandleFunc("POST /api/targets", s.createTarget) mux.HandleFunc("GET /api/targets/{id}", s.getTarget) + mux.HandleFunc("PATCH /api/targets/{id}", s.updateTarget) mux.HandleFunc("POST /api/targets/{id}/drain", s.targetMode(model.TargetDraining)) mux.HandleFunc("POST /api/targets/{id}/enable", s.targetMode(model.TargetOnline)) mux.HandleFunc("POST /api/targets/{id}/disable", s.targetMode(model.TargetDisabled)) @@ -494,6 +495,26 @@ type createTargetReq struct { Bootstrap string `json:"bootstrap"` } +type updateTargetReq struct { + Name *string `json:"name"` + Host *string `json:"host"` + User *string `json:"user"` + WorkRoot *string `json:"work_root"` + Labels *[]string `json:"labels"` + CapacitySessions *int `json:"capacity_sessions"` + SSHPort *int `json:"ssh_port"` + IdentityFile *string `json:"identity_file"` + Bootstrap *string `json:"bootstrap"` +} + +func copyJSONMap(m model.JSONMap) model.JSONMap { + out := model.JSONMap{} + for k, v := range m { + out[k] = v + } + return out +} + // createTarget registers a machine (local or SSH). For SSH it health-checks the // host, so the response status reflects whether it is reachable (this can take a // few seconds for an unreachable host). @@ -530,6 +551,75 @@ func (s *Server) createTarget(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]any{"target": created, "doctor": doctor}) } +func (s *Server) updateTarget(w http.ResponseWriter, r *http.Request) { + var req updateTargetReq + if err := decode(r, &req); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + old, err := s.st.GetTarget(r.PathValue("id")) + if err != nil { + writeErr(w, httpStatusFor(err), err) + return + } + if req.CapacitySessions != nil && *req.CapacitySessions < 1 { + writeErr(w, http.StatusBadRequest, errors.New("capacity_sessions must be at least 1")) + return + } + + next := *old + if req.Name != nil { + next.Name = *req.Name + } + if req.Host != nil { + next.Host = *req.Host + } + if req.User != nil { + next.User = *req.User + } + if req.WorkRoot != nil { + next.WorkRoot = *req.WorkRoot + } + if req.Labels != nil { + next.Labels = *req.Labels + } + if req.CapacitySessions != nil { + next.CapacitySessions = *req.CapacitySessions + } + next.Metadata = copyJSONMap(old.Metadata) + if req.SSHPort != nil { + if *req.SSHPort > 0 { + next.Metadata["ssh_port"] = float64(*req.SSHPort) + } else { + delete(next.Metadata, "ssh_port") + } + } + if req.IdentityFile != nil { + if *req.IdentityFile != "" { + next.Metadata["identity_file"] = *req.IdentityFile + } else { + delete(next.Metadata, "identity_file") + } + } + if req.Bootstrap != nil { + if *req.Bootstrap != "" { + next.Metadata["bootstrap"] = *req.Bootstrap + } else { + delete(next.Metadata, "bootstrap") + } + } + if len(next.Metadata) == 0 { + next.Metadata = nil + } + + updated, err := s.st.UpdateTarget(old.ID, &next) + if err != nil { + writeErr(w, httpStatusFor(err), err) + return + } + writeJSON(w, http.StatusOK, updated) +} + // doctorTarget re-runs readiness diagnostics on a target. func (s *Server) doctorTarget(w http.ResponseWriter, r *http.Request) { rep, err := s.o.DoctorTarget(r.Context(), r.PathValue("id")) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 972a5c3..c5dd6fa 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -54,6 +54,21 @@ func postJSON(t *testing.T, url string, body any) *http.Response { return resp } +func patchJSON(t *testing.T, url string, body any) *http.Response { + t.Helper() + b, _ := json.Marshal(body) + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(b)) + if err != nil { + t.Fatalf("PATCH %s: %v", url, err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PATCH %s: %v", url, err) + } + return resp +} + func TestHealth_ReportsOK(t *testing.T) { srv, _, _ := newTestServer(t) var body struct { @@ -325,3 +340,75 @@ func TestCreateTarget_RegistersAndHealthChecks(t *testing.T) { t.Fatalf("target not listed: %+v", targets) } } + +func TestUpdateTarget_PatchesFieldsAndMetadata(t *testing.T) { + srv, _, st := newTestServer(t) + tgt := &model.Target{Name: "remote", Kind: model.TargetSSH, Status: model.TargetOnline, + WorkRoot: "/old", CapacitySessions: 4, Host: "old-host", User: "old-user", + Labels: []string{"old"}, CPUSummary: "cpu", MemorySummary: "mem", DiskSummary: "disk", + Metadata: model.JSONMap{"ssh_port": float64(22), "identity_file": "/old/key", "keep": "value"}} + if err := st.CreateTarget(tgt); err != nil { + t.Fatalf("create target: %v", err) + } + if err := st.ClaimTargetSlot(tgt.ID); err != nil { + t.Fatalf("claim slot: %v", err) + } + if err := st.SetTargetStatus(tgt.ID, model.TargetDraining); err != nil { + t.Fatalf("drain target: %v", err) + } + + resp := patchJSON(t, srv.URL+"/api/targets/"+tgt.ID, map[string]any{ + "name": "renamed", + "host": "new-host", + "user": "bot", + "work_root": "/new", + "labels": []string{"gpu", "linux"}, + "capacity_sessions": 2, + "ssh_port": 2222, + "identity_file": "/home/bot/.ssh/id_ed25519", + "bootstrap": "echo ready", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("patch status=%d, want 200", resp.StatusCode) + } + var got model.Target + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode patch response: %v", err) + } + if got.Name != "renamed" || got.Host != "new-host" || got.User != "bot" || + got.WorkRoot != "/new" || got.CapacitySessions != 2 || got.AvailableSessions != 1 { + t.Fatalf("updated target wrong: %+v", got) + } + if got.Kind != model.TargetSSH || got.Status != model.TargetDraining || + got.CPUSummary != "cpu" || got.MemorySummary != "mem" || got.DiskSummary != "disk" { + t.Fatalf("immutable/status summary fields should be preserved: %+v", got) + } + if len(got.Labels) != 2 || got.Labels[0] != "gpu" || got.Labels[1] != "linux" { + t.Fatalf("labels not updated: %+v", got.Labels) + } + if got.Metadata["ssh_port"] != float64(2222) || + got.Metadata["identity_file"] != "/home/bot/.ssh/id_ed25519" || + got.Metadata["bootstrap"] != "echo ready" || + got.Metadata["keep"] != "value" { + t.Fatalf("metadata not updated/preserved: %#v", got.Metadata) + } +} + +func TestUpdateTarget_RejectsMissingAndInvalidCapacity(t *testing.T) { + srv, _, st := newTestServer(t) + resp := patchJSON(t, srv.URL+"/api/targets/missing", map[string]any{"name": "x"}) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("missing patch status=%d, want 404", resp.StatusCode) + } + tgt := &model.Target{Name: "remote", Kind: model.TargetSSH, WorkRoot: "/w", CapacitySessions: 1} + if err := st.CreateTarget(tgt); err != nil { + t.Fatalf("create target: %v", err) + } + resp = patchJSON(t, srv.URL+"/api/targets/"+tgt.ID, map[string]any{"capacity_sessions": 0}) + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("invalid capacity status=%d, want 400", resp.StatusCode) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index af82950..7db977d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -153,6 +153,67 @@ func TestTargetCapacity_LimitsScheduling(t *testing.T) { } } +func TestUpdateTarget_AdjustsAvailableForCapacityChange(t *testing.T) { + st := newTestStore(t) + tgt := &model.Target{Name: "t", Kind: model.TargetLocal, Status: model.TargetOnline, + WorkRoot: "/w", CapacitySessions: 4} + if err := st.CreateTarget(tgt); err != nil { + t.Fatalf("create target: %v", err) + } + if err := st.ClaimTargetSlot(tgt.ID); err != nil { + t.Fatalf("claim 1: %v", err) + } + if err := st.ClaimTargetSlot(tgt.ID); err != nil { + t.Fatalf("claim 2: %v", err) + } + + updated, err := st.UpdateTarget(tgt.ID, &model.Target{ + Name: "renamed", Kind: model.TargetSSH, Host: "box", User: "bot", + WorkRoot: "/new", Labels: []string{"gpu"}, CapacitySessions: 6, + Metadata: model.JSONMap{"ssh_port": float64(2222)}, + }) + if err != nil { + t.Fatalf("update grow: %v", err) + } + if updated.Kind != model.TargetLocal || updated.Status != model.TargetOnline { + t.Fatalf("kind/status should be preserved, got %s/%s", updated.Kind, updated.Status) + } + if updated.CapacitySessions != 6 || updated.AvailableSessions != 4 { + t.Fatalf("capacity/available=%d/%d, want 6/4", updated.CapacitySessions, updated.AvailableSessions) + } + if updated.Name != "renamed" || updated.Host != "box" || updated.User != "bot" || + updated.WorkRoot != "/new" || len(updated.Labels) != 1 || updated.Labels[0] != "gpu" { + t.Fatalf("mutable fields not updated: %+v", updated) + } + + updated, err = st.UpdateTarget(tgt.ID, &model.Target{ + Name: "renamed", WorkRoot: "/new", CapacitySessions: 1, + Metadata: model.JSONMap{"ssh_port": float64(2222)}, + }) + if err != nil { + t.Fatalf("update shrink: %v", err) + } + if updated.CapacitySessions != 1 || updated.AvailableSessions != 0 { + t.Fatalf("capacity/available=%d/%d, want 1/0", updated.CapacitySessions, updated.AvailableSessions) + } +} + +func TestUpdateTarget_RejectsInvalidOrMissingTarget(t *testing.T) { + st := newTestStore(t) + tgt := &model.Target{Name: "t", Kind: model.TargetLocal, WorkRoot: "/w", CapacitySessions: 1} + if err := st.CreateTarget(tgt); err != nil { + t.Fatalf("create target: %v", err) + } + if _, err := st.UpdateTarget(tgt.ID, &model.Target{Name: "t", WorkRoot: "/w"}); err == nil { + t.Fatal("capacity below 1 should be rejected") + } + if _, err := st.UpdateTarget("missing", &model.Target{ + Name: "missing", WorkRoot: "/w", CapacitySessions: 1, + }); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing target should return ErrNotFound, got %v", err) + } +} + // The dashboard query must return only small rows — never transcript content, // no matter how large the transcript grows. func TestDashboard_NoTranscriptBlobs(t *testing.T) { diff --git a/internal/store/targets.go b/internal/store/targets.go index f7ea7d6..aeff1b3 100644 --- a/internal/store/targets.go +++ b/internal/store/targets.go @@ -41,6 +41,54 @@ func (s *Store) CreateTarget(t *model.Target) error { return err } +// UpdateTarget updates a target's mutable operator configuration and preserves +// scheduling state, health summaries, and last-seen data. Capacity changes keep +// currently used slots accounted for: available becomes max(new-used, 0). +func (s *Store) UpdateTarget(id string, t *model.Target) (*model.Target, error) { + if t.CapacitySessions < 1 { + return nil, fmt.Errorf("store: capacity_sessions must be at least 1") + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() //nolint:errcheck + + row := tx.QueryRow(`SELECT `+targetCols+` FROM targets WHERE id = ?`, id) + old, err := scanTarget(row) + if err != nil { + return nil, err + } + used := old.CapacitySessions - old.AvailableSessions + if used < 0 { + used = 0 + } + available := t.CapacitySessions - used + if available < 0 { + available = 0 + } + + if _, err := tx.Exec( + `UPDATE targets + SET name = ?, host = ?, user = ?, work_root = ?, labels = ?, + capacity_sessions = ?, available_sessions = ?, metadata = ? + WHERE id = ?`, + t.Name, nullStr(t.Host), nullStr(t.User), t.WorkRoot, joinLabels(t.Labels), + t.CapacitySessions, available, t.Metadata, id, + ); err != nil { + return nil, err + } + row = tx.QueryRow(`SELECT `+targetCols+` FROM targets WHERE id = ?`, id) + updated, err := scanTarget(row) + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return updated, nil +} + var targetCols = `id, name, kind, status, host, user, work_root, labels, capacity_sessions, available_sessions, cpu_summary, memory_summary, disk_summary, last_seen_at, metadata` diff --git a/ui/src/api.ts b/ui/src/api.ts index 270fbca..0136e96 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -265,6 +265,14 @@ export function put(url: string, body?: unknown): Promise { }).then(parse); } +export function patch(url: string, body?: unknown): Promise { + return fetch(url, { + method: "PATCH", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }).then(parse); +} + export function del(url: string): Promise { return fetch(url, { method: "DELETE" }).then(parse); } diff --git a/ui/src/icons.tsx b/ui/src/icons.tsx index e00e657..fa4d8bd 100644 --- a/ui/src/icons.tsx +++ b/ui/src/icons.tsx @@ -17,6 +17,7 @@ const PATHS = { check: "M20 6 9 17l-5-5", alert: "M12 9v4 M12 17h.01 M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z", copy: "M9 9h11v11H9z M15 9V4H4v11h5", + pencil: "M18 2.5a2.1 2.1 0 0 1 3 3L8 18.5 4 20l1.5-4L18 2.5Z M15.5 5 19 8.5", stop: "M7 7h10v10H7z", play: "m7 5 12 7-12 7Z", menu: "M4 6h16 M4 12h16 M4 18h16", diff --git a/ui/src/pages/Targets.tsx b/ui/src/pages/Targets.tsx index 62700dd..7a4591d 100644 --- a/ui/src/pages/Targets.tsx +++ b/ui/src/pages/Targets.tsx @@ -53,6 +53,7 @@ export function TargetsPage() { function TargetCard({ t, onChanged }: { t: api.Target; onChanged: () => void }) { const [doctor, setDoctor] = useState(null); const [busy, setBusy] = useState(null); + const [editing, setEditing] = useState(false); const act = async (name: string, fn: () => Promise) => { setBusy(name); @@ -160,6 +161,10 @@ function TargetCard({ t, onChanged }: { t: api.Target; onChanged: () => void }) {busy === "doctor" ? "Checking…" : "Doctor"} + {t.status === "online" && ( )} + {editing && ( + setEditing(false)} + onSaved={() => { + setEditing(false); + onChanged(); + }} + /> + )} ); } +function metadataText(t: api.Target, key: string) { + const value = t.metadata?.[key]; + return typeof value === "string" ? value : ""; +} + +function metadataNumber(t: api.Target, key: string) { + const value = t.metadata?.[key]; + return typeof value === "number" ? String(value) : ""; +} + +function EditTargetModal({ + target, + onClose, + onSaved, +}: { + target: api.Target; + onClose: () => void; + onSaved: () => void; +}) { + const [name, setName] = useState(target.name); + const [host, setHost] = useState(target.host ?? ""); + const [user, setUser] = useState(target.user ?? ""); + const [workRoot, setWorkRoot] = useState(target.work_root); + const [capacity, setCapacity] = useState(String(target.capacity_sessions)); + const [labels, setLabels] = useState((target.labels ?? []).join(", ")); + const [sshPort, setSSHPort] = useState(metadataNumber(target, "ssh_port")); + const [identityFile, setIdentityFile] = useState( + metadataText(target, "identity_file"), + ); + const [bootstrap, setBootstrap] = useState(metadataText(target, "bootstrap")); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (busy) return; + setBusy(true); + setErr(null); + try { + const parsedCapacity = parseInt(capacity, 10); + const parsedPort = parseInt(sshPort, 10); + await api.patch(`/api/targets/${target.id}`, { + name, + host, + user, + work_root: workRoot, + capacity_sessions: Number.isFinite(parsedCapacity) + ? parsedCapacity + : target.capacity_sessions, + labels: labels + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ssh_port: Number.isFinite(parsedPort) ? parsedPort : 0, + identity_file: identityFile, + bootstrap, + }); + onSaved(); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : String(ex)); + } finally { + setBusy(false); + } + }; + + return ( + +
+
+ + setName(e.target.value)} + required + autoFocus + /> + + + setHost(e.target.value)} /> + +
+
+ + setUser(e.target.value)} /> + + + setCapacity(e.target.value)} + inputMode="numeric" + /> + +
+ + setWorkRoot(e.target.value)} + required + /> + + + setLabels(e.target.value)} + placeholder="gpu, linux" + /> + +
+ + setSSHPort(e.target.value)} + inputMode="numeric" + placeholder="22" + /> + + + setIdentityFile(e.target.value)} + placeholder="~/.ssh/id_ed25519" + /> + +
+ + setBootstrap(e.target.value)} + placeholder="optional command" + /> + + {err &&

{err}

} +
+ + +
+
+
+ ); +} + function AddTargetModal({ onClose, onAdded,