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 importOriginalLoading overview…
+ } + + if (errorMessage) { + return{errorMessage}
+ } + + const allNodes = nodes ?? [] + if (allNodes.length === 0) { + returnNo nodes yet.
+ } + + return ( +