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.
This commit is contained in:
@@ -24,3 +24,51 @@ func (app *App) actionWidgetsListHandler(writer http.ResponseWriter, request *ht
|
|||||||
Widgets: settings.WidgetsForNode(store, node.ID),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", app.actionLogsListHandler)
|
||||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs/{filename}", app.actionLogsGetHandler)
|
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/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/node-logs", app.nodeLogsListHandler)
|
||||||
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
|
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -2066,3 +2066,86 @@ button.save-dirty {
|
|||||||
word-break: break-word;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,7 +276,8 @@ describe('App', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', { name: 'Overview', level: 2 }),
|
screen.getByRole('heading', { name: 'Overview', level: 2 }),
|
||||||
).toBeInTheDocument()
|
).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 () => {
|
it('shows node count badges on resource nav items', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react'
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { OverviewPanel } from './OverviewPanel'
|
||||||
import {
|
import {
|
||||||
beginMyTOTP,
|
beginMyTOTP,
|
||||||
changeMyPassword,
|
changeMyPassword,
|
||||||
@@ -4388,6 +4389,8 @@ function App() {
|
|||||||
setSidebarCountsRefreshToken((token) => token + 1)
|
setSidebarCountsRefreshToken((token) => token + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : activeSection === 'overview' ? (
|
||||||
|
<OverviewPanel />
|
||||||
) : (
|
) : (
|
||||||
<p>Coming soon</p>
|
<p>Coming soon</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 () => {
|
it('sends idle-exempt header when requested', async () => {
|
||||||
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
|
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -1425,3 +1425,13 @@ export async function fetchNodeActionWidgets(
|
|||||||
}
|
}
|
||||||
return (await response.json()) as ActionWidgetsResponse
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user