mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 07:04:44 +00:00
Fix install script: strip v prefix from version for archive name
This commit is contained in:
parent
8002158a45
commit
1d564b738d
16 changed files with 552 additions and 26 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
type bootstrapResult struct {
|
||||
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
|
||||
|
|
|
|||
97
apps/node-agent/cmd/node-agent/control_plane_test.go
Normal file
97
apps/node-agent/cmd/node-agent/control_plane_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<User | null>(null);
|
||||
const [nodes, setNodes] = useState<NasNode[]>([]);
|
||||
const [exports, setExports] = useState<StorageExport[]>([]);
|
||||
const [selectedExportId, setSelectedExportId] = useState<string | null>(null);
|
||||
const [mountProfile, setMountProfile] = useState<MountProfile | null>(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 (
|
||||
<main className="min-h-screen bg-background">
|
||||
|
|
@ -135,6 +150,9 @@ export default function Home() {
|
|||
<Badge variant="secondary">
|
||||
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{nodes.length === 1 ? "1 node" : `${nodes.length} nodes`}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
|
|
@ -172,12 +190,107 @@ export default function Home() {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nodes</CardTitle>
|
||||
<CardDescription>
|
||||
Machines registered to your account and their current connection
|
||||
state.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{onlineNodeCount} online
|
||||
</Badge>
|
||||
{degradedNodeCount > 0 && (
|
||||
<Badge variant="secondary" className="bg-amber-500/15 text-amber-700 dark:text-amber-300">
|
||||
{degradedNodeCount} degraded
|
||||
</Badge>
|
||||
)}
|
||||
{offlineNodeCount > 0 && (
|
||||
<Badge variant="outline">{offlineNodeCount} offline</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
|
||||
<HardDrives size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No nodes registered yet. Install and start the node agent on
|
||||
the machine that owns your files.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex flex-col gap-3 rounded-2xl border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium text-foreground">
|
||||
{node.displayName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{node.machineId}
|
||||
</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={node.status === "offline" ? "outline" : "secondary"}
|
||||
className={
|
||||
node.status === "online"
|
||||
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
|
||||
: node.status === "degraded"
|
||||
? "bg-amber-500/15 text-amber-700 dark:text-amber-300"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{node.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div>
|
||||
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Node ID
|
||||
</dt>
|
||||
<dd className="truncate text-xs text-foreground">
|
||||
{node.id}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Last seen
|
||||
</dt>
|
||||
<dd className="text-xs text-foreground">
|
||||
{formatTimestamp(node.lastSeenAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Address
|
||||
</dt>
|
||||
<dd className="truncate text-xs text-foreground">
|
||||
{node.directAddress ?? node.relayAddress ?? "Unavailable"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_400px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exports</CardTitle>
|
||||
<CardDescription>
|
||||
Storage exports registered with this control plane.
|
||||
Connected storage exports that are currently mountable.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant="secondary">
|
||||
|
|
@ -192,8 +305,9 @@ export default function Home() {
|
|||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
|
||||
<HardDrives size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -371,3 +485,17 @@ export default function Home() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<User> {
|
|||
return apiFetch<User>("/api/v1/auth/me");
|
||||
}
|
||||
|
||||
export async function listNodes(): Promise<NasNode[]> {
|
||||
return apiFetch<NasNode[]>("/api/v1/nodes");
|
||||
}
|
||||
|
||||
export async function listExports(): Promise<StorageExport[]> {
|
||||
return apiFetch<StorageExport[]>("/api/v1/exports");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue