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),
|
||||
})
|
||||
}
|
||||
|
||||
// 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/{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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user