diff --git a/service/internal/api/action_widgets_handlers.go b/service/internal/api/action_widgets_handlers.go index a47b1df..693c51c 100644 --- a/service/internal/api/action_widgets_handlers.go +++ b/service/internal/api/action_widgets_handlers.go @@ -24,3 +24,51 @@ func (app *App) actionWidgetsListHandler(writer http.ResponseWriter, request *ht Widgets: settings.WidgetsForNode(store, node.ID), }) } + +// actionWidgetsListAllHandler returns widget snapshots for every node the caller can access. +func (app *App) actionWidgetsListAllHandler(writer http.ResponseWriter, request *http.Request) { + if err := app.authorizeNodesRead(request); err != nil { + writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()}) + return + } + + user, ok := UserFromContext(request.Context()) + if !ok { + writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"}) + return + } + + settingsPayload, err := loadSettingsOrDefault(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + nodesStore, err := settings.LoadNodesOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + accessibleNodeIDs := make(map[string]struct{}, len(nodesStore.Nodes)) + for _, node := range nodesStore.Nodes { + if userCanAccessNode(user, settingsPayload.Groups, node) { + accessibleNodeIDs[node.ID] = struct{}{} + } + } + + widgetStore, err := settings.LoadActionWidgetsOrEmpty(app.ConfigDir) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()}) + return + } + + filtered := make([]settings.ActionWidgetSnapshot, 0) + for _, widget := range widgetStore.Widgets { + if _, ok := accessibleNodeIDs[widget.NodeID]; ok { + filtered = append(filtered, widget) + } + } + + writeJSON(writer, http.StatusOK, actionWidgetsResponse{Widgets: filtered}) +} diff --git a/service/internal/api/action_widgets_handlers_test.go b/service/internal/api/action_widgets_handlers_test.go new file mode 100644 index 0000000..60880d1 --- /dev/null +++ b/service/internal/api/action_widgets_handlers_test.go @@ -0,0 +1,179 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth" + "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings" +) + +func TestActionWidgetsListAllFiltersByNodeAccess(t *testing.T) { + configDir := t.TempDir() + _ = seedCompletedSetup(t, configDir) + + keyBytes, err := settings.KeyFromEnv() + if err != nil { + t.Fatalf("KeyFromEnv: %v", err) + } + now := time.Now().UTC() + passwordHash, err := auth.HashPassword("correct horse battery staple extra") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + payload := settings.Settings{ + SetupCompleted: true, + Groups: []settings.Group{ + { + Name: settings.AdministratorsGroupName, + ScopeKind: settings.GroupScopeGroup, + ScopeName: "Admin Group", + Permissions: allPermissionList(), + }, + { + Name: "TeamA", + ScopeKind: settings.GroupScopeGroup, + ScopeName: "Team A", + Permissions: []string{"nodes.read"}, + }, + }, + Security: settings.DefaultSecuritySettings(), + Network: settings.DefaultNetworkSettings(), + } + if err := settings.SaveSettings(configDir, payload); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + if err := settings.SavePasswords(configDir, settings.PasswordStore{ + Users: []settings.UserCredential{ + { + ID: "admin-id", + Username: "Admin", + PasswordHash: passwordHash, + Enabled: true, + GroupNames: []string{settings.AdministratorsGroupName}, + CreatedAt: now, + PasswordChangedAt: now, + }, + { + ID: "reader-id", + Username: "reader", + PasswordHash: passwordHash, + Enabled: true, + GroupNames: []string{"TeamA"}, + CreatedAt: now, + PasswordChangedAt: now, + }, + }, + }, keyBytes); err != nil { + t.Fatalf("SavePasswords: %v", err) + } + + accessibleNodeID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + hiddenNodeID := "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee" + if err := settings.SaveNodes(configDir, settings.NodeStore{ + Nodes: []settings.Node{ + { + ID: accessibleNodeID, + Kind: settings.NodeKindContainer, + Name: "visible", + HostIP: "10.0.0.1", + Username: "root", + GroupName: "TeamA", + CreatedAt: now, + }, + { + ID: hiddenNodeID, + Kind: settings.NodeKindVM, + Name: "hidden", + HostIP: "10.0.0.2", + Username: "root", + GroupName: settings.AdministratorsGroupName, + CreatedAt: now, + }, + }, + }); err != nil { + t.Fatalf("SaveNodes: %v", err) + } + + if err := settings.SaveActionWidgets(configDir, settings.ActionWidgetStore{ + Widgets: []settings.ActionWidgetSnapshot{ + { + NodeID: accessibleNodeID, + ItemID: "item-visible", + GroupID: "group-1", + WidgetType: settings.ActionWidgetTypeMemory, + Title: "Memory", + UpdatedAt: now, + Memory: &settings.MemoryWidgetData{ + Total: 1024, + Free: 512, + }, + }, + { + NodeID: hiddenNodeID, + ItemID: "item-hidden", + GroupID: "group-2", + WidgetType: settings.ActionWidgetTypeDisk, + Title: "Disk", + UpdatedAt: now, + Disk: &settings.DiskWidgetData{ + Size: "50G", + Used: "12G", + UsePercent: "24%", + MountedOn: "/", + }, + }, + }, + }); err != nil { + t.Fatalf("SaveActionWidgets: %v", err) + } + + router := NewRouter(configDir) + loginRecorder := httptest.NewRecorder() + loginRequest := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/login", + bytes.NewReader([]byte(`{"username":"reader","password":"correct horse battery staple extra"}`)), + ) + loginRequest.Header.Set("Content-Type", "application/json") + router.ServeHTTP(loginRecorder, loginRequest) + if loginRecorder.Code != http.StatusOK { + t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String()) + } + var readerCookie *http.Cookie + for _, cookie := range loginRecorder.Result().Cookies() { + if cookie.Name == auth.SessionCookieName { + readerCookie = cookie + break + } + } + if readerCookie == nil { + t.Fatal("reader session cookie missing") + } + + listRequest := withSession( + httptest.NewRequest(http.MethodGet, "/api/v1/action-widgets", nil), + readerCookie, + ) + listRecorder := httptest.NewRecorder() + router.ServeHTTP(listRecorder, listRequest) + if listRecorder.Code != http.StatusOK { + t.Fatalf("list status = %d body=%s", listRecorder.Code, listRecorder.Body.String()) + } + + var response actionWidgetsResponse + if err := json.Unmarshal(listRecorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode: %v", err) + } + if len(response.Widgets) != 1 { + t.Fatalf("widgets = %+v", response.Widgets) + } + if response.Widgets[0].NodeID != accessibleNodeID { + t.Fatalf("node_id = %q", response.Widgets[0].NodeID) + } +} diff --git a/service/internal/api/router.go b/service/internal/api/router.go index 89e7394..784071a 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -84,6 +84,7 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) { mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs", app.actionLogsListHandler) mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs/{filename}", app.actionLogsGetHandler) mux.HandleFunc("GET /api/v1/nodes/{id}/action-widgets", app.actionWidgetsListHandler) + mux.HandleFunc("GET /api/v1/action-widgets", app.actionWidgetsListAllHandler) mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler) mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler) diff --git a/webui/src/App.css b/webui/src/App.css index 5e696dd..64607fd 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -2066,3 +2066,86 @@ button.save-dirty { word-break: break-word; } +.overview-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.overview-kind-section { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.overview-kind-heading { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--ctp-subtext1); +} + +.overview-node-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(13.75rem, 1fr)); + gap: 0.65rem; +} + +.overview-node-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; + margin: 0; + padding: 0.65rem 0.75rem; + border: 1px solid var(--ctp-surface1); + border-radius: 0.45rem; + background: color-mix(in srgb, var(--ctp-surface0) 55%, transparent); + color: inherit; + text-align: left; + cursor: pointer; + min-width: 0; + font: inherit; +} + +.overview-node-card:hover { + border-color: var(--ctp-overlay0); + background: color-mix(in srgb, var(--ctp-surface0) 75%, transparent); +} + +.overview-node-card:focus-visible { + outline: 2px solid var(--ctp-blue); + outline-offset: 2px; +} + +.overview-node-card-heading { + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; + width: 100%; +} + +.overview-node-card-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.95rem; +} + +.overview-node-metric { + margin: 0; + font-size: 0.78rem; + color: var(--ctp-subtext0); + line-height: 1.35; + word-break: break-word; +} + +.overview-node-metric-label { + display: inline-block; + min-width: 2.4rem; + margin-right: 0.35rem; + color: var(--ctp-overlay1); + font-weight: 600; +} + diff --git a/webui/src/App.test.tsx b/webui/src/App.test.tsx index 6b5467f..be4b759 100644 --- a/webui/src/App.test.tsx +++ b/webui/src/App.test.tsx @@ -276,7 +276,8 @@ describe('App', () => { expect( screen.getByRole('heading', { name: 'Overview', level: 2 }), ).toBeInTheDocument() - expect(screen.getByText('Coming soon')).toBeInTheDocument() + expect(await screen.findByText('No nodes yet.')).toBeInTheDocument() + expect(screen.queryByText('Coming soon')).not.toBeInTheDocument() }) it('shows node count badges on resource nav items', async () => { diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 7f5828b..afab531 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type FormEvent } from 'react' +import { OverviewPanel } from './OverviewPanel' import { beginMyTOTP, changeMyPassword, @@ -4388,6 +4389,8 @@ function App() { setSidebarCountsRefreshToken((token) => token + 1) }} /> + ) : activeSection === 'overview' ? ( + ) : (

Coming soon

)} diff --git a/webui/src/OverviewPanel.test.tsx b/webui/src/OverviewPanel.test.tsx new file mode 100644 index 0000000..5bbf56b --- /dev/null +++ b/webui/src/OverviewPanel.test.tsx @@ -0,0 +1,288 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + formatDiskLine, + formatMemoryAmount, + formatMemoryLine, + freePercentFromUsePercent, + OverviewPanel, +} from './OverviewPanel' +import { navigateTo } from './navigation' + +vi.mock('./navigation', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + navigateTo: vi.fn(), + } +}) + +describe('formatMemoryAmount', () => { + it('uses MiB, GiB, and TiB thresholds', () => { + expect(formatMemoryAmount(512)).toBe('512 MiB') + expect(formatMemoryAmount(1024)).toBe('1.0 GiB') + expect(formatMemoryAmount(1024 * 1024)).toBe('1.0 TiB') + }) +}) + +describe('formatMemoryLine', () => { + it('formats free / total', () => { + expect( + formatMemoryLine({ + total: 2048, + used: 1000, + free: 512, + shared: 0, + buff_cache: 536, + available: 800, + }), + ).toBe('512 MiB / 2.0 GiB') + }) +}) + +describe('freePercentFromUsePercent', () => { + it('derives free percent from use percent', () => { + expect(freePercentFromUsePercent('24%')).toBe(76) + expect(freePercentFromUsePercent('24')).toBe(76) + expect(freePercentFromUsePercent('bad')).toBeNull() + }) +}) + +describe('formatDiskLine', () => { + it('formats used / size with free percent', () => { + expect( + formatDiskLine({ + filesystem: '/dev/sda1', + size: '50G', + used: '12G', + avail: '38G', + use_percent: '24%', + mounted_on: '/', + }), + ).toBe('12G / 50G (76% free)') + }) +}) + +describe('OverviewPanel', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('groups nodes by kind and shows metrics when widgets exist', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + + if (url.includes('/api/v1/action-widgets')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + widgets: [ + { + node_id: 'c1', + item_id: 'm1', + group_id: 'g1', + library_action_id: 'mem', + widget_type: 'memory', + title: 'Memory', + updated_at: '2026-01-01T00:00:00.000Z', + exit_code: 0, + raw_stdout: '', + memory: { + total: 1024, + used: 400, + free: 512, + shared: 0, + buff_cache: 112, + available: 600, + }, + }, + { + node_id: 'c1', + item_id: 'd1', + group_id: 'g1', + library_action_id: 'disk', + widget_type: 'disk', + title: 'Disk', + updated_at: '2026-01-01T00:00:00.000Z', + exit_code: 0, + raw_stdout: '', + disk: { + filesystem: '/dev/sda1', + size: '50G', + used: '12G', + avail: '38G', + use_percent: '24%', + mounted_on: '/', + }, + }, + ], + }), + }) + } + + if (url.includes('/api/v1/nodes')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + nodes: [ + { + id: 'c1', + kind: 'container', + name: 'alpha', + host_ip: '10.0.0.1', + username: 'root', + group_name: 'Administrators', + public_key: '', + key_algo: 'ed25519', + created_at: '2026-01-01T00:00:00.000Z', + health_ok: true, + }, + { + id: 'd1', + kind: 'docker', + name: 'dock', + host_ip: '10.0.0.2', + username: 'root', + group_name: 'Administrators', + public_key: '', + key_algo: 'ed25519', + created_at: '2026-01-01T00:00:00.000Z', + }, + { + id: 'v1', + kind: 'vm', + name: 'vm-one', + host_ip: '10.0.0.3', + username: 'root', + group_name: 'Administrators', + public_key: '', + key_algo: 'ed25519', + created_at: '2026-01-01T00:00:00.000Z', + }, + ], + }), + }) + } + + return Promise.resolve({ + ok: true, + json: async () => ({}), + }) + }), + ) + + render() + + expect(await screen.findByRole('heading', { name: 'Containers' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Docker' })).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'Virtual Machines' }), + ).toBeInTheDocument() + + expect(screen.getByRole('button', { name: /alpha/ })).toBeInTheDocument() + expect(screen.getByText('512 MiB / 1.0 GiB')).toBeInTheDocument() + expect(screen.getByText('12G / 50G (76% free)')).toBeInTheDocument() + + expect(screen.getByRole('button', { name: /dock/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /vm-one/ })).toBeInTheDocument() + }) + + it('omits metric lines when widgets are missing', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + + if (url.includes('/api/v1/action-widgets')) { + return Promise.resolve({ + ok: true, + json: async () => ({ widgets: [] }), + }) + } + + if (url.includes('/api/v1/nodes')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + nodes: [ + { + id: 'c1', + kind: 'container', + name: 'bare', + host_ip: '10.0.0.1', + username: 'root', + group_name: 'Administrators', + public_key: '', + key_algo: 'ed25519', + created_at: '2026-01-01T00:00:00.000Z', + }, + ], + }), + }) + } + + return Promise.resolve({ + ok: true, + json: async () => ({}), + }) + }), + ) + + render() + + expect(await screen.findByRole('button', { name: /bare/ })).toBeInTheDocument() + expect(screen.queryByText('RAM')).not.toBeInTheDocument() + expect(screen.queryByText('Disk')).not.toBeInTheDocument() + }) + + it('navigates to node detail on card click', async () => { + const user = userEvent.setup() + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + + if (url.includes('/api/v1/action-widgets')) { + return Promise.resolve({ + ok: true, + json: async () => ({ widgets: [] }), + }) + } + + if (url.includes('/api/v1/nodes')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + nodes: [ + { + id: 'c1', + kind: 'container', + name: 'alpha', + host_ip: '10.0.0.1', + username: 'root', + group_name: 'Administrators', + public_key: '', + key_algo: 'ed25519', + created_at: '2026-01-01T00:00:00.000Z', + }, + ], + }), + }) + } + + return Promise.resolve({ + ok: true, + json: async () => ({}), + }) + }), + ) + + render() + + await user.click(await screen.findByRole('button', { name: /alpha/ })) + expect(navigateTo).toHaveBeenCalledWith('/containers/c1') + }) +}) diff --git a/webui/src/OverviewPanel.tsx b/webui/src/OverviewPanel.tsx new file mode 100644 index 0000000..1f95d03 --- /dev/null +++ b/webui/src/OverviewPanel.tsx @@ -0,0 +1,259 @@ +import { useEffect, useState } from 'react' +import { + type ActionWidgetSnapshot, + type DiskWidgetData, + type MemoryWidgetData, + type Node, + type NodeKind, + fetchAllActionWidgets, + fetchNodes, +} from './api/client' +import { SECTION_TITLES } from './documentTitle' +import { + navigateTo, + pathFor, + type ResourceSectionId, +} from './navigation' + +const KIND_SECTIONS: ReadonlyArray<{ + kind: NodeKind + section: ResourceSectionId +}> = [ + { kind: 'container', section: 'containers' }, + { kind: 'docker', section: 'docker' }, + { kind: 'vm', section: 'vms' }, +] + +/** Format a MiB quantity with auto MiB / GiB / TiB units. */ +export function formatMemoryAmount(mib: number): string { + const absolute = Math.abs(mib) + if (absolute >= 1024 * 1024) { + return `${(mib / (1024 * 1024)).toFixed(1)} TiB` + } + if (absolute >= 1024) { + return `${(mib / 1024).toFixed(1)} GiB` + } + return `${Math.round(mib)} MiB` +} + +/** Format free / total RAM from a memory widget snapshot. */ +export function formatMemoryLine(memory: MemoryWidgetData): string { + return `${formatMemoryAmount(memory.free)} / ${formatMemoryAmount(memory.total)}` +} + +/** Parse df Use% into a free percent integer, or null if unparsable. */ +export function freePercentFromUsePercent(usePercent: string): number | null { + const match = usePercent.trim().match(/^(\d+(?:\.\d+)?)\s*%?$/) + if (!match) { + return null + } + const used = Number(match[1]) + if (!Number.isFinite(used)) { + return null + } + return Math.max(0, Math.min(100, Math.round(100 - used))) +} + +/** Format used / total (free%) from a disk widget snapshot. */ +export function formatDiskLine(disk: DiskWidgetData): string { + const freePercent = freePercentFromUsePercent(disk.use_percent) + const base = `${disk.used} / ${disk.size}` + if (freePercent === null) { + return base + } + return `${base} (${freePercent}% free)` +} + +function pickMemoryWidget( + widgets: ReadonlyArray, +): MemoryWidgetData | null { + for (const widget of widgets) { + if (widget.widget_type === 'memory' && widget.memory) { + return widget.memory + } + } + return null +} + +function pickDiskWidget( + widgets: ReadonlyArray, +): DiskWidgetData | null { + const diskWidgets = widgets.filter( + (widget) => widget.widget_type === 'disk' && widget.disk, + ) + if (diskWidgets.length === 0) { + return null + } + const root = diskWidgets.find((widget) => widget.disk?.mounted_on === '/') + return (root ?? diskWidgets[0]).disk ?? null +} + +function healthDotClass(healthOk: boolean | null | undefined): string { + if (healthOk === true) { + return 'node-health-dot node-health-dot-ok' + } + if (healthOk === false) { + return 'node-health-dot node-health-dot-error' + } + return 'node-health-dot node-health-dot-unknown' +} + +function healthDotLabel(healthOk: boolean | null | undefined): string { + if (healthOk === true) { + return 'Healthy' + } + if (healthOk === false) { + return 'Unhealthy' + } + return 'Health unknown' +} + +function groupWidgetsByNode( + widgets: ReadonlyArray, +): Map { + const byNode = new Map() + for (const widget of widgets) { + const existing = byNode.get(widget.node_id) + if (existing) { + existing.push(widget) + } else { + byNode.set(widget.node_id, [widget]) + } + } + return byNode +} + +function OverviewNodeCard({ + node, + widgets, + section, +}: { + node: Node + widgets: ReadonlyArray + section: ResourceSectionId +}) { + const memory = pickMemoryWidget(widgets) + const disk = pickDiskWidget(widgets) + + return ( + + ) +} + +export function OverviewPanel() { + const [nodes, setNodes] = useState | null>(null) + const [widgetsByNode, setWidgetsByNode] = useState< + Map + >(() => new Map()) + const [errorMessage, setErrorMessage] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let isCancelled = false + + async function loadOverview() { + setIsLoading(true) + setErrorMessage(null) + try { + const [nodesResponse, widgetsResponse] = await Promise.all([ + fetchNodes(), + fetchAllActionWidgets(), + ]) + if (isCancelled) { + return + } + setNodes(nodesResponse.nodes) + setWidgetsByNode(groupWidgetsByNode(widgetsResponse.widgets ?? [])) + } catch (error) { + if (!isCancelled) { + setErrorMessage( + error instanceof Error ? error.message : 'Failed to load overview', + ) + setNodes([]) + setWidgetsByNode(new Map()) + } + } finally { + if (!isCancelled) { + setIsLoading(false) + } + } + } + + void loadOverview() + + return () => { + isCancelled = true + } + }, []) + + if (isLoading) { + return

Loading overview…

+ } + + if (errorMessage) { + return

{errorMessage}

+ } + + const allNodes = nodes ?? [] + if (allNodes.length === 0) { + return

No nodes yet.

+ } + + return ( +
+ {KIND_SECTIONS.map(({ kind, section }) => { + const sectionNodes = allNodes.filter((node) => node.kind === kind) + if (sectionNodes.length === 0) { + return null + } + return ( +
+

{SECTION_TITLES[section]}

+
+ {sectionNodes.map((node) => ( + + ))} +
+
+ ) + })} +
+ ) +} diff --git a/webui/src/api/client.test.ts b/webui/src/api/client.test.ts index 4304953..eeddf6d 100644 --- a/webui/src/api/client.test.ts +++ b/webui/src/api/client.test.ts @@ -611,6 +611,21 @@ describe('nodes API', () => { ) }) + it('fetches all action widgets', async () => { + const { fetchAllActionWidgets } = await import('./client') + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ widgets: [] }), + }) + + await fetchAllActionWidgets(fetchMock) + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/action-widgets', + expect.objectContaining({ credentials: 'include' }), + ) + }) + it('sends idle-exempt header when requested', async () => { const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client') const fetchMock = vi.fn().mockResolvedValue({ diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index 1e5d931..004d867 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -1425,3 +1425,13 @@ export async function fetchNodeActionWidgets( } return (await response.json()) as ActionWidgetsResponse } + +export async function fetchAllActionWidgets( + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await apiFetch('/api/v1/action-widgets', {}, fetchImpl) + if (!response.ok) { + throw new Error(await readErrorMessage(response)) + } + return (await response.json()) as ActionWidgetsResponse +}