Compare commits

..
2 Commits
Author SHA1 Message Date
Squid 6eef839526 Add Overview page with kind-grouped node cards and RAM/disk metrics.
Batch-fetch action widgets so the dashboard can show free/total memory and disk usage without N+1 requests.
2026-07-20 21:25:55 +02:00
Squid be255341e5 Require confirmation and block deleting library actions still referenced by node groups. 2026-07-20 21:00:24 +02:00
14 changed files with 1105 additions and 1 deletions
@@ -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})
}
@@ -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)
}
}
+11
View File
@@ -49,6 +49,7 @@ var (
errActionIDRequired = errors.New("action id is required")
errActionWidgetTypeNeeded = errors.New("widget_type is required when show_widget is true")
errActionWidgetTypeBad = errors.New("widget_type must be memory, disk, or raw")
errActionInUse = errors.New("action is used by node action groups; remove or clone to customize first")
)
func actionsGetHandler(configDir string) http.HandlerFunc {
@@ -221,6 +222,16 @@ func actionsDeleteHandler(configDir string) http.HandlerFunc {
return
}
groups, err := settings.LoadNodeActionGroupsOrEmpty(configDir)
if err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
return
}
if settings.LibraryActionInUse(groups, actionID) {
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: errActionInUse.Error()})
return
}
store.Actions = append(store.Actions[:index], store.Actions[index+1:]...)
if err := settings.SaveActions(configDir, store); err != nil {
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
@@ -257,3 +257,147 @@ func TestActionsMutationsRequirePermissions(t *testing.T) {
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
}
}
func TestActionsDeleteBlockedWhenLibraryReferenced(t *testing.T) {
configDir := t.TempDir()
cookie := seedCompletedSetup(t, configDir)
node := seedNodeForActions(t, configDir)
router := NewRouter(configDir)
createBody := []byte(`{"name":"Echo host","kind":"shell","body":"echo hi"}`)
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
createRec := httptest.NewRecorder()
router.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusOK {
t.Fatalf("create status %d body=%s", createRec.Code, createRec.Body.String())
}
var created actionsResponseTest
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
t.Fatalf("decode create: %v", err)
}
customID := ""
for _, action := range created.Actions {
if !action.Builtin {
customID = action.ID
break
}
}
if customID == "" {
t.Fatal("custom action missing")
}
groupBody, _ := json.Marshal(map[string]any{
"name": "Nightly",
"schedule": map[string]any{
"enabled": false,
"kind": "interval",
"every_seconds": 3600,
"times": []any{},
},
})
groupReq := withSession(
httptest.NewRequest(http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups", bytes.NewReader(groupBody)),
cookie,
)
groupRec := httptest.NewRecorder()
router.ServeHTTP(groupRec, groupReq)
if groupRec.Code != http.StatusOK {
t.Fatalf("create group status=%d body=%s", groupRec.Code, groupRec.Body.String())
}
var groupResp actionGroupResponse
if err := json.Unmarshal(groupRec.Body.Bytes(), &groupResp); err != nil {
t.Fatalf("decode group: %v", err)
}
groupID := groupResp.Group.ID
addBody, _ := json.Marshal(map[string]any{
"source": "library",
"library_action_id": customID,
})
addReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
bytes.NewReader(addBody),
),
cookie,
)
addRec := httptest.NewRecorder()
router.ServeHTTP(addRec, addReq)
if addRec.Code != http.StatusOK {
t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String())
}
var withItem actionGroupResponse
if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil {
t.Fatalf("decode add: %v", err)
}
itemID := withItem.Group.Items[0].ID
blockedReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+customID, nil),
cookie,
)
blockedRec := httptest.NewRecorder()
router.ServeHTTP(blockedRec, blockedReq)
if blockedRec.Code != http.StatusConflict {
t.Fatalf("expected delete conflict %d, got %d body=%s", http.StatusConflict, blockedRec.Code, blockedRec.Body.String())
}
var blockedErr apiErrorResponse
if err := json.NewDecoder(blockedRec.Body).Decode(&blockedErr); err != nil {
t.Fatalf("decode conflict: %v", err)
}
if blockedErr.Error != errActionInUse.Error() {
t.Fatalf("conflict error = %q, want %q", blockedErr.Error, errActionInUse.Error())
}
listReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
listRec := httptest.NewRecorder()
router.ServeHTTP(listRec, listReq)
var listed actionsResponseTest
if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil {
t.Fatalf("decode list: %v", err)
}
stillPresent := false
for _, action := range listed.Actions {
if action.ID == customID {
stillPresent = true
break
}
}
if !stillPresent {
t.Fatal("action should remain after blocked delete")
}
cloneReq := withSession(
httptest.NewRequest(
http.MethodPost,
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+itemID+"/clone",
nil,
),
cookie,
)
cloneRec := httptest.NewRecorder()
router.ServeHTTP(cloneRec, cloneReq)
if cloneRec.Code != http.StatusOK {
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
}
deleteReq := withSession(
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+customID, nil),
cookie,
)
deleteRec := httptest.NewRecorder()
router.ServeHTTP(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK {
t.Fatalf("delete after clone status %d body=%s", deleteRec.Code, deleteRec.Body.String())
}
var deleted actionsResponseTest
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
t.Fatalf("decode delete: %v", err)
}
for _, action := range deleted.Actions {
if action.ID == customID {
t.Fatal("custom action should be gone after delete")
}
}
}
+1
View File
@@ -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)
@@ -128,6 +128,23 @@ func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup
return result
}
// LibraryActionInUse reports whether any group item still references actionID as a library source.
// Local clones (source local) do not count even if they previously came from that action.
func LibraryActionInUse(store NodeActionGroupStore, actionID string) bool {
actionID = strings.TrimSpace(actionID)
if actionID == "" {
return false
}
for _, group := range store.Groups {
for _, item := range group.Items {
if item.Source == ActionItemSourceLibrary && item.LibraryActionID == actionID {
return true
}
}
}
return false
}
// UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves.
func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error {
store, err := LoadNodeActionGroupsOrEmpty(dir)
@@ -66,6 +66,48 @@ func TestNodeActionGroupsRoundTrip(t *testing.T) {
}
}
func TestLibraryActionInUse(t *testing.T) {
store := NodeActionGroupStore{
Groups: []NodeActionGroup{
{
ID: "g1",
NodeID: "n1",
Name: "Nightly",
Items: []NodeActionItem{
{
ID: "lib-item",
Source: ActionItemSourceLibrary,
LibraryActionID: "custom-1",
},
{
ID: "local-item",
Source: ActionItemSourceLocal,
Name: "Echo",
Kind: ActionKindShell,
Body: "echo hi",
},
},
},
},
}
if !LibraryActionInUse(store, "custom-1") {
t.Fatal("expected library action custom-1 to be in use")
}
if LibraryActionInUse(store, "other") {
t.Fatal("expected unrelated action not in use")
}
store.Groups[0].Items[0] = NodeActionItem{
ID: "cloned",
Source: ActionItemSourceLocal,
Name: "Echo host",
Kind: ActionKindShell,
Body: "echo",
}
if LibraryActionInUse(store, "custom-1") {
t.Fatal("expected cloned local item not to count as in use")
}
}
func TestIsScheduleDueInterval(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.Local)
group := NodeActionGroup{
+83
View File
@@ -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;
}
+2 -1
View File
@@ -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 () => {
+6
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react'
import { OverviewPanel } from './OverviewPanel'
import {
beginMyTOTP,
changeMyPassword,
@@ -1318,6 +1319,9 @@ function ActionsConfigTab() {
if (action.builtin || !canDeleteActions) {
return
}
if (!window.confirm(`Delete action “${action.name}”?`)) {
return
}
setSubmitError(null)
try {
const response = await deleteAction(action.id)
@@ -4385,6 +4389,8 @@ function App() {
setSidebarCountsRefreshToken((token) => token + 1)
}}
/>
) : activeSection === 'overview' ? (
<OverviewPanel />
) : (
<p>Coming soon</p>
)}
+288
View File
@@ -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<typeof import('./navigation')>()
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(<OverviewPanel />)
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(<OverviewPanel />)
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(<OverviewPanel />)
await user.click(await screen.findByRole('button', { name: /alpha/ }))
expect(navigateTo).toHaveBeenCalledWith('/containers/c1')
})
})
+259
View File
@@ -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<ActionWidgetSnapshot>,
): MemoryWidgetData | null {
for (const widget of widgets) {
if (widget.widget_type === 'memory' && widget.memory) {
return widget.memory
}
}
return null
}
function pickDiskWidget(
widgets: ReadonlyArray<ActionWidgetSnapshot>,
): 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<ActionWidgetSnapshot>,
): Map<string, ActionWidgetSnapshot[]> {
const byNode = new Map<string, ActionWidgetSnapshot[]>()
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<ActionWidgetSnapshot>
section: ResourceSectionId
}) {
const memory = pickMemoryWidget(widgets)
const disk = pickDiskWidget(widgets)
return (
<button
type="button"
className="overview-node-card"
onClick={() => {
navigateTo(pathFor(section, 'overview', 'overview', node.id, 'overview'))
}}
>
<div className="overview-node-card-heading">
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-label={healthDotLabel(node.health_ok)}
role="img"
/>
<strong className="overview-node-card-title">{node.name}</strong>
</div>
{memory ? (
<p className="overview-node-metric">
<span className="overview-node-metric-label">RAM</span>
{formatMemoryLine(memory)}
</p>
) : null}
{disk ? (
<p className="overview-node-metric">
<span className="overview-node-metric-label">Disk</span>
{formatDiskLine(disk)}
</p>
) : null}
</button>
)
}
export function OverviewPanel() {
const [nodes, setNodes] = useState<ReadonlyArray<Node> | null>(null)
const [widgetsByNode, setWidgetsByNode] = useState<
Map<string, ActionWidgetSnapshot[]>
>(() => new Map())
const [errorMessage, setErrorMessage] = useState<string | null>(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 <p className="config-hint">Loading overview</p>
}
if (errorMessage) {
return <p className="form-error" role="alert">{errorMessage}</p>
}
const allNodes = nodes ?? []
if (allNodes.length === 0) {
return <p className="config-hint">No nodes yet.</p>
}
return (
<div className="overview-panel">
{KIND_SECTIONS.map(({ kind, section }) => {
const sectionNodes = allNodes.filter((node) => node.kind === kind)
if (sectionNodes.length === 0) {
return null
}
return (
<section
key={kind}
className="overview-kind-section"
aria-label={SECTION_TITLES[section]}
>
<h3 className="overview-kind-heading">{SECTION_TITLES[section]}</h3>
<div className="overview-node-grid">
{sectionNodes.map((node) => (
<OverviewNodeCard
key={node.id}
node={node}
widgets={widgetsByNode.get(node.id) ?? []}
section={section}
/>
))}
</div>
</section>
)
})}
</div>
)
}
+15
View File
@@ -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({
+10
View File
@@ -1425,3 +1425,13 @@ export async function fetchNodeActionWidgets(
}
return (await response.json()) as ActionWidgetsResponse
}
export async function fetchAllActionWidgets(
fetchImpl: typeof fetch = fetch,
): Promise<ActionWidgetsResponse> {
const response = await apiFetch('/api/v1/action-widgets', {}, fetchImpl)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionWidgetsResponse
}