diff --git a/CLAUDE.md b/CLAUDE.md index e9338c1..67e34d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,3 +65,8 @@ ## Live operations - If modifying the live Netcup deployment, only stop the `betternas` node process unless the user explicitly asks to modify the deployed backend service. + +## Node availability UX + +- Prefer default UI behavior that does not present disconnected nodes as mountable. +- Surface connected and disconnected node state in the product when node availability is exposed. diff --git a/apps/control-plane/cmd/control-plane/app.go b/apps/control-plane/cmd/control-plane/app.go index 1aac5b0..4fb54b8 100644 --- a/apps/control-plane/cmd/control-plane/app.go +++ b/apps/control-plane/cmd/control-plane/app.go @@ -1,6 +1,8 @@ package main import ( + "sort" + "strings" "time" ) @@ -10,6 +12,7 @@ type appConfig struct { statePath string dbPath string sessionTTL time.Duration + nodeOfflineThreshold time.Duration registrationEnabled bool corsOrigin string } @@ -21,7 +24,13 @@ type app struct { store store } +const defaultNodeOfflineThreshold = 2 * time.Minute + func newApp(config appConfig, startedAt time.Time) (*app, error) { + if config.nodeOfflineThreshold <= 0 { + config.nodeOfflineThreshold = defaultNodeOfflineThreshold + } + var s store var err error if config.dbPath != "" { @@ -41,6 +50,68 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) { }, nil } +func (a *app) presentedNode(node nasNode) nasNode { + presented := copyNasNode(node) + if !nodeHeartbeatIsFresh(presented.LastSeenAt, a.now().UTC(), a.config.nodeOfflineThreshold) { + presented.Status = "offline" + } + return presented +} + +func (a *app) listNodes(ownerID string) []nasNode { + nodes := a.store.listNodes(ownerID) + presented := make([]nasNode, 0, len(nodes)) + for _, node := range nodes { + presented = append(presented, a.presentedNode(node)) + } + + sort.Slice(presented, func(i, j int) bool { + return presented[i].ID < presented[j].ID + }) + + return presented +} + +func (a *app) listConnectedExports(ownerID string) []storageExport { + exports := a.store.listExports(ownerID) + connected := make([]storageExport, 0, len(exports)) + for _, export := range exports { + context, ok := a.store.exportContext(export.ID, ownerID) + if !ok { + continue + } + if !nodeIsConnected(a.presentedNode(context.node)) { + continue + } + connected = append(connected, export) + } + + return connected +} + +func nodeHeartbeatIsFresh(lastSeenAt string, referenceTime time.Time, threshold time.Duration) bool { + lastSeenAt = strings.TrimSpace(lastSeenAt) + if threshold <= 0 || lastSeenAt == "" { + return false + } + + parsedLastSeenAt, err := time.Parse(time.RFC3339, lastSeenAt) + if err != nil { + return false + } + + referenceTime = referenceTime.UTC() + if parsedLastSeenAt.After(referenceTime) { + return true + } + + return referenceTime.Sub(parsedLastSeenAt) <= threshold +} + +func nodeIsConnected(node nasNode) bool { + return node.Status == "online" || node.Status == "degraded" +} + type nextcloudBackendStatus struct { Configured bool `json:"configured"` BaseURL string `json:"baseUrl"` diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index d89196e..298e55f 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -36,6 +36,16 @@ func newAppFromEnv(startedAt time.Time) (*app, error) { sessionTTL = parsedSessionTTL } + nodeOfflineThreshold := defaultNodeOfflineThreshold + rawNodeOfflineThreshold := strings.TrimSpace(env("BETTERNAS_NODE_OFFLINE_THRESHOLD", "2m")) + if rawNodeOfflineThreshold != "" { + parsedNodeOfflineThreshold, err := time.ParseDuration(rawNodeOfflineThreshold) + if err != nil { + return nil, err + } + nodeOfflineThreshold = parsedNodeOfflineThreshold + } + app, err := newApp( appConfig{ version: env("BETTERNAS_VERSION", "0.1.0-dev"), @@ -43,6 +53,7 @@ func newAppFromEnv(startedAt time.Time) (*app, error) { statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"), dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"), sessionTTL: sessionTTL, + nodeOfflineThreshold: nodeOfflineThreshold, registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true", corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""), }, diff --git a/apps/control-plane/cmd/control-plane/server.go b/apps/control-plane/cmd/control-plane/server.go index 810dbb0..e4057f9 100644 --- a/apps/control-plane/cmd/control-plane/server.go +++ b/apps/control-plane/cmd/control-plane/server.go @@ -36,6 +36,7 @@ func (a *app) handler() http.Handler { mux.HandleFunc("POST /api/v1/auth/login", a.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/logout", a.handleAuthLogout) mux.HandleFunc("GET /api/v1/auth/me", a.handleAuthMe) + mux.HandleFunc("GET /api/v1/nodes", a.handleNodesList) mux.HandleFunc("POST /api/v1/nodes/register", a.handleNodeRegister) mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat) mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports) @@ -74,6 +75,15 @@ func (a *app) handleVersion(w http.ResponseWriter, _ *http.Request) { }) } +func (a *app) handleNodesList(w http.ResponseWriter, r *http.Request) { + currentUser, ok := a.requireSessionUser(w, r) + if !ok { + return + } + + writeJSON(w, http.StatusOK, a.listNodes(currentUser.ID)) +} + func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) { currentUser, ok := a.requireSessionUser(w, r) if !ok { @@ -177,7 +187,7 @@ func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID)) + writeJSON(w, http.StatusOK, a.listConnectedExports(currentUser.ID)) } func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { @@ -202,6 +212,11 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { http.Error(w, errExportNotFound.Error(), http.StatusNotFound) return } + context.node = a.presentedNode(context.node) + if !nodeIsConnected(context.node) { + http.Error(w, errMountTargetUnavailable.Error(), http.StatusServiceUnavailable) + return + } mountURL, err := buildMountURL(context) if err != nil { diff --git a/apps/control-plane/cmd/control-plane/sqlite_store.go b/apps/control-plane/cmd/control-plane/sqlite_store.go index a0050ed..0ae277e 100644 --- a/apps/control-plane/cmd/control-plane/sqlite_store.go +++ b/apps/control-plane/cmd/control-plane/sqlite_store.go @@ -346,6 +346,27 @@ func (s *sqliteStore) listExports(ownerID string) []storageExport { return exports } +func (s *sqliteStore) listNodes(ownerID string) []nasNode { + rows, err := s.db.Query("SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE owner_id = ? ORDER BY id", ownerID) + if err != nil { + return nil + } + defer rows.Close() + + var nodes []nasNode + for rows.Next() { + node := s.scanNode(rows) + if node.ID != "" { + nodes = append(nodes, node) + } + } + if nodes == nil { + nodes = []nasNode{} + } + + return nodes +} + func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport { rows, err := s.db.Query("SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE node_id = ? ORDER BY id", nodeID) if err != nil { @@ -401,15 +422,29 @@ func (s *sqliteStore) exportContext(exportID string, ownerID string) (exportCont } func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) { + row := s.db.QueryRow( + "SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?", + nodeID) + n := s.scanNode(row) + if n.ID == "" { + return nasNode{}, false + } + + return n, true +} + +type sqliteNodeScanner interface { + Scan(dest ...any) error +} + +func (s *sqliteStore) scanNode(scanner sqliteNodeScanner) nasNode { var n nasNode var directAddr, relayAddr sql.NullString var lastSeenAt sql.NullString var ownerID sql.NullString - err := s.db.QueryRow( - "SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?", - nodeID).Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr) + err := scanner.Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr) if err != nil { - return nasNode{}, false + return nasNode{} } if ownerID.Valid { n.OwnerID = ownerID.String @@ -423,7 +458,7 @@ func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) { if relayAddr.Valid { n.RelayAddress = &relayAddr.String } - return n, true + return n } func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) { diff --git a/apps/control-plane/cmd/control-plane/store.go b/apps/control-plane/cmd/control-plane/store.go index 6ebb65a..23eb7e6 100644 --- a/apps/control-plane/cmd/control-plane/store.go +++ b/apps/control-plane/cmd/control-plane/store.go @@ -320,6 +320,25 @@ func (s *memoryStore) listExports(ownerID string) []storageExport { return exports } +func (s *memoryStore) listNodes(ownerID string) []nasNode { + s.mu.RLock() + defer s.mu.RUnlock() + + nodes := make([]nasNode, 0, len(s.state.NodesByID)) + for _, node := range s.state.NodesByID { + if node.OwnerID != ownerID { + continue + } + nodes = append(nodes, copyNasNode(node)) + } + + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].ID < nodes[j].ID + }) + + return nodes +} + func (s *memoryStore) exportContext(exportID string, ownerID string) (exportContext, bool) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/apps/control-plane/cmd/control-plane/store_iface.go b/apps/control-plane/cmd/control-plane/store_iface.go index 409e894..2449b96 100644 --- a/apps/control-plane/cmd/control-plane/store_iface.go +++ b/apps/control-plane/cmd/control-plane/store_iface.go @@ -9,6 +9,7 @@ type store interface { upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error listExports(ownerID string) []storageExport + listNodes(ownerID string) []nasNode exportContext(exportID string, ownerID string) (exportContext, bool) nodeByID(nodeID string) (nasNode, bool) diff --git a/apps/node-agent/cmd/node-agent/app.go b/apps/node-agent/cmd/node-agent/app.go index 679ea94..469663d 100644 --- a/apps/node-agent/cmd/node-agent/app.go +++ b/apps/node-agent/cmd/node-agent/app.go @@ -1,13 +1,16 @@ package main import ( + "context" "crypto/subtle" "encoding/json" "errors" "fmt" + "log" "net/http" "os" "strings" + "time" "golang.org/x/net/webdav" ) @@ -27,6 +30,7 @@ type app struct { authUsername string authPassword string exportMounts []exportMount + controlPlane *controlPlaneSession } type exportMount struct { @@ -69,17 +73,92 @@ func newAppFromEnv() (*app, error) { if err != nil { return nil, err } + var controlPlane *controlPlaneSession if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" { - if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil { + session, err := bootstrapNodeAgentFromEnv(exportPaths) + if err != nil { return nil, err } + controlPlane = &session } - return newApp(appConfig{ + app, err := newApp(appConfig{ exportPaths: exportPaths, authUsername: authUsername, authPassword: authPassword, }) + if err != nil { + return nil, err + } + app.controlPlane = controlPlane + return app, nil +} + +func (a *app) startControlPlaneLoop(ctx context.Context) { + if a.controlPlane == nil { + return + } + + go runNodeHeartbeatLoop( + ctx, + &http.Client{Timeout: 5 * time.Second}, + a.controlPlane.controlPlaneURL, + a.controlPlane.sessionToken, + a.controlPlane.nodeID, + a.controlPlane.heartbeatInterval, + time.Now, + log.Default(), + ) +} + +func (a *app) controlPlaneEnabled() bool { + return a.controlPlane != nil +} + +func defaultNodeHeartbeatInterval() time.Duration { + return 30 * time.Second +} + +func heartbeatIntervalFromEnv() (time.Duration, error) { + rawInterval := strings.TrimSpace(env("BETTERNAS_NODE_HEARTBEAT_INTERVAL", "30s")) + if rawInterval == "" { + return defaultNodeHeartbeatInterval(), nil + } + + interval, err := time.ParseDuration(rawInterval) + if err != nil { + return 0, err + } + if interval <= 0 { + return 0, errors.New("BETTERNAS_NODE_HEARTBEAT_INTERVAL must be greater than zero") + } + + return interval, nil +} + +func runNodeHeartbeatLoop( + ctx context.Context, + client *http.Client, + baseURL string, + sessionToken string, + nodeID string, + interval time.Duration, + now func() time.Time, + logger *log.Logger, +) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := sendNodeHeartbeatAt(client, baseURL, sessionToken, nodeID, now().UTC()); err != nil && logger != nil { + logger.Printf("betternas node heartbeat failed: %v", err) + } + } + } } func exportPathsFromEnv() ([]string, error) { diff --git a/apps/node-agent/cmd/node-agent/control_plane.go b/apps/node-agent/cmd/node-agent/control_plane.go index 570bbec..dca7dda 100644 --- a/apps/node-agent/cmd/node-agent/control_plane.go +++ b/apps/node-agent/cmd/node-agent/control_plane.go @@ -14,8 +14,11 @@ import ( "time" ) -type bootstrapResult struct { - nodeID string +type controlPlaneSession struct { + nodeID string + controlPlaneURL string + sessionToken string + heartbeatInterval time.Duration } type nodeRegistrationRequest struct { @@ -58,19 +61,23 @@ type nodeHeartbeatRequest struct { LastSeenAt string `json:"lastSeenAt"` } -func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { +func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error) { controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com")) if controlPlaneURL == "" { - return bootstrapResult{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required") + return controlPlaneSession{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required") } username, err := requiredEnv("BETTERNAS_USERNAME") if err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err } password, err := requiredEnv("BETTERNAS_PASSWORD") if err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err + } + heartbeatInterval, err := heartbeatIntervalFromEnv() + if err != nil { + return controlPlaneSession{}, err } machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username))) @@ -82,7 +89,7 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { client := &http.Client{Timeout: 5 * time.Second} sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password) if err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err } registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{ @@ -93,17 +100,22 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"), }) if err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err } if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err } - if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil { - return bootstrapResult{}, err + if err := sendNodeHeartbeatAt(client, controlPlaneURL, sessionToken, registration.ID, time.Now().UTC()); err != nil { + return controlPlaneSession{}, err } - return bootstrapResult{nodeID: registration.ID}, nil + return controlPlaneSession{ + nodeID: registration.ID, + controlPlaneURL: controlPlaneURL, + sessionToken: sessionToken, + heartbeatInterval: heartbeatInterval, + }, nil } func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) { @@ -168,10 +180,14 @@ func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token } func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error { + return sendNodeHeartbeatAt(client, baseURL, token, nodeID, time.Now().UTC()) +} + +func sendNodeHeartbeatAt(client *http.Client, baseURL string, token string, nodeID string, at time.Time) error { response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{ NodeID: nodeID, Status: "online", - LastSeenAt: time.Now().UTC().Format(time.RFC3339), + LastSeenAt: at.UTC().Format(time.RFC3339), }) if err != nil { return err diff --git a/apps/node-agent/cmd/node-agent/control_plane_test.go b/apps/node-agent/cmd/node-agent/control_plane_test.go new file mode 100644 index 0000000..884ec56 --- /dev/null +++ b/apps/node-agent/cmd/node-agent/control_plane_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func TestRunNodeHeartbeatLoopSendsRecurringHeartbeats(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var heartbeats []nodeHeartbeatRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/nodes/dev-node/heartbeat" { + http.NotFound(w, r) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read heartbeat body: %v", err) + } + _ = r.Body.Close() + + var heartbeat nodeHeartbeatRequest + if err := json.Unmarshal(body, &heartbeat); err != nil { + t.Fatalf("decode heartbeat body: %v", err) + } + + mu.Lock() + heartbeats = append(heartbeats, heartbeat) + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + runNodeHeartbeatLoop( + ctx, + server.Client(), + server.URL, + "session-token", + "dev-node", + 10*time.Millisecond, + func() time.Time { return time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC) }, + log.New(io.Discard, "", 0), + ) + close(done) + }() + + deadline := time.Now().Add(500 * time.Millisecond) + for { + mu.Lock() + count := len(heartbeats) + mu.Unlock() + if count >= 2 { + break + } + if time.Now().After(deadline) { + t.Fatalf("expected recurring heartbeats, got %d", count) + } + time.Sleep(10 * time.Millisecond) + } + + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("heartbeat loop did not stop after context cancellation") + } + + mu.Lock() + defer mu.Unlock() + for _, heartbeat := range heartbeats { + if heartbeat.NodeID != "dev-node" { + t.Fatalf("expected node ID dev-node, got %q", heartbeat.NodeID) + } + if heartbeat.Status != "online" { + t.Fatalf("expected status online, got %q", heartbeat.Status) + } + if heartbeat.LastSeenAt != "2025-01-01T12:00:00Z" { + t.Fatalf("expected fixed lastSeenAt, got %q", heartbeat.LastSeenAt) + } + } +} diff --git a/apps/node-agent/cmd/node-agent/main.go b/apps/node-agent/cmd/node-agent/main.go index 1b7e84c..1842e33 100644 --- a/apps/node-agent/cmd/node-agent/main.go +++ b/apps/node-agent/cmd/node-agent/main.go @@ -1,9 +1,12 @@ package main import ( + "context" "log" "net/http" "os" + "os/signal" + "syscall" "time" ) @@ -14,6 +17,19 @@ func main() { log.Fatal(err) } + controlPlaneCtx, stopControlPlane := context.WithCancel(context.Background()) + defer stopControlPlane() + if app.controlPlaneEnabled() { + app.startControlPlaneLoop(controlPlaneCtx) + } + + signalContext, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSignals() + go func() { + <-signalContext.Done() + stopControlPlane() + }() + server := &http.Server{ Addr: ":" + port, Handler: app.handler(), diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index abe1a38..44bff34 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -12,11 +12,13 @@ import { import { isAuthenticated, listExports, + listNodes, issueMountProfile, logout, getMe, type StorageExport, type MountProfile, + type NasNode, type User, ApiError, } from "@/lib/api"; @@ -38,6 +40,7 @@ import { CopyField } from "./copy-field"; export default function Home() { const router = useRouter(); const [user, setUser] = useState(null); + const [nodes, setNodes] = useState([]); const [exports, setExports] = useState([]); const [selectedExportId, setSelectedExportId] = useState(null); const [mountProfile, setMountProfile] = useState(null); @@ -52,8 +55,13 @@ export default function Home() { async function load() { try { - const [me, exps] = await Promise.all([getMe(), listExports()]); + const [me, registeredNodes, exps] = await Promise.all([ + getMe(), + listNodes(), + listExports(), + ]); setUser(me); + setNodes(registeredNodes); setExports(exps); } catch (err) { if (err instanceof ApiError && err.status === 401) { @@ -100,6 +108,13 @@ export default function Home() { const selectedExport = selectedExportId ? (exports.find((e) => e.id === selectedExportId) ?? null) : null; + const onlineNodeCount = nodes.filter((node) => node.status === "online").length; + const degradedNodeCount = nodes.filter( + (node) => node.status === "degraded", + ).length; + const offlineNodeCount = nodes.filter( + (node) => node.status === "offline", + ).length; return (
@@ -135,6 +150,9 @@ export default function Home() { {exports.length === 1 ? "1 export" : `${exports.length} exports`} + + {nodes.length === 1 ? "1 node" : `${nodes.length} nodes`} + {user && ( @@ -172,12 +190,107 @@ export default function Home() { )} + + + Nodes + + Machines registered to your account and their current connection + state. + + +
+ + {onlineNodeCount} online + + {degradedNodeCount > 0 && ( + + {degradedNodeCount} degraded + + )} + {offlineNodeCount > 0 && ( + {offlineNodeCount} offline + )} +
+
+
+ + {nodes.length === 0 ? ( +
+ +

+ No nodes registered yet. Install and start the node agent on + the machine that owns your files. +

+
+ ) : ( +
+ {nodes.map((node) => ( +
+
+
+ + {node.displayName} + + + {node.machineId} + +
+ + {node.status} + +
+ +
+
+
+ Node ID +
+
+ {node.id} +
+
+
+
+ Last seen +
+
+ {formatTimestamp(node.lastSeenAt)} +
+
+
+
+ Address +
+
+ {node.directAddress ?? node.relayAddress ?? "Unavailable"} +
+
+
+
+ ))} +
+ )} +
+
+
Exports - Storage exports registered with this control plane. + Connected storage exports that are currently mountable. @@ -192,8 +305,9 @@ export default function Home() {

- No exports registered yet. Start the node agent and connect - it to this control plane. + {nodes.length === 0 + ? "No exports registered yet. Start the node agent and connect it to this control plane." + : "No connected exports right now. Start the node agent or wait for a disconnected node to reconnect."}

) : ( @@ -371,3 +485,17 @@ export default function Home() {
); } + +function formatTimestamp(value: string): string { + const trimmedValue = value.trim(); + if (trimmedValue === "") { + return "Never"; + } + + const parsed = new Date(trimmedValue); + if (Number.isNaN(parsed.getTime())) { + return trimmedValue; + } + + return parsed.toLocaleString(); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 448527a..6b20980 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,5 +1,16 @@ const API_URL = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || ""; +export interface NasNode { + id: string; + machineId: string; + displayName: string; + agentVersion: string; + status: "online" | "offline" | "degraded"; + lastSeenAt: string; + directAddress: string | null; + relayAddress: string | null; +} + export interface StorageExport { id: string; nasNodeId: string; @@ -146,6 +157,10 @@ export async function getMe(): Promise { return apiFetch("/api/v1/auth/me"); } +export async function listNodes(): Promise { + return apiFetch("/api/v1/nodes"); +} + export async function listExports(): Promise { return apiFetch("/api/v1/exports"); } diff --git a/packages/contracts/openapi/betternas.v1.yaml b/packages/contracts/openapi/betternas.v1.yaml index 1902c06..b9b5381 100644 --- a/packages/contracts/openapi/betternas.v1.yaml +++ b/packages/contracts/openapi/betternas.v1.yaml @@ -18,6 +18,22 @@ paths: responses: "200": description: Control-plane version + /api/v1/nodes: + get: + operationId: listNodes + security: + - UserSession: [] + responses: + "200": + description: Node list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/NasNode" + "401": + description: Unauthorized /api/v1/nodes/register: post: operationId: registerNode diff --git a/packages/contracts/src/foundation.ts b/packages/contracts/src/foundation.ts index 04e513b..21514e4 100644 --- a/packages/contracts/src/foundation.ts +++ b/packages/contracts/src/foundation.ts @@ -1,4 +1,5 @@ export const FOUNDATION_API_ROUTES = { + listNodes: "/api/v1/nodes", registerNode: "/api/v1/nodes/register", nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat", nodeExports: "/api/v1/nodes/:nodeId/exports", diff --git a/scripts/install-betternas-node.sh b/scripts/install-betternas-node.sh index 280d536..4d15ea8 100755 --- a/scripts/install-betternas-node.sh +++ b/scripts/install-betternas-node.sh @@ -37,7 +37,8 @@ case "$arch_name" in ;; esac -archive_name="${binary_name}_${version}_${os}_${arch}.tar.gz" +version_stripped="${version#v}" +archive_name="${binary_name}_${version_stripped}_${os}_${arch}.tar.gz" download_url="${download_base_url}/${version}/${archive_name}" tmp_dir="$(mktemp -d)"