mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 03:00: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
|
## 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.
|
- 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -10,6 +12,7 @@ type appConfig struct {
|
||||||
statePath string
|
statePath string
|
||||||
dbPath string
|
dbPath string
|
||||||
sessionTTL time.Duration
|
sessionTTL time.Duration
|
||||||
|
nodeOfflineThreshold time.Duration
|
||||||
registrationEnabled bool
|
registrationEnabled bool
|
||||||
corsOrigin string
|
corsOrigin string
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +24,13 @@ type app struct {
|
||||||
store store
|
store store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultNodeOfflineThreshold = 2 * time.Minute
|
||||||
|
|
||||||
func newApp(config appConfig, startedAt time.Time) (*app, error) {
|
func newApp(config appConfig, startedAt time.Time) (*app, error) {
|
||||||
|
if config.nodeOfflineThreshold <= 0 {
|
||||||
|
config.nodeOfflineThreshold = defaultNodeOfflineThreshold
|
||||||
|
}
|
||||||
|
|
||||||
var s store
|
var s store
|
||||||
var err error
|
var err error
|
||||||
if config.dbPath != "" {
|
if config.dbPath != "" {
|
||||||
|
|
@ -41,6 +50,68 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) {
|
||||||
}, nil
|
}, 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 {
|
type nextcloudBackendStatus struct {
|
||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
BaseURL string `json:"baseUrl"`
|
BaseURL string `json:"baseUrl"`
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,16 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
|
||||||
sessionTTL = parsedSessionTTL
|
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(
|
app, err := newApp(
|
||||||
appConfig{
|
appConfig{
|
||||||
version: env("BETTERNAS_VERSION", "0.1.0-dev"),
|
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"),
|
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
|
||||||
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"),
|
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"),
|
||||||
sessionTTL: sessionTTL,
|
sessionTTL: sessionTTL,
|
||||||
|
nodeOfflineThreshold: nodeOfflineThreshold,
|
||||||
registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true",
|
registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true",
|
||||||
corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""),
|
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/login", a.handleAuthLogin)
|
||||||
mux.HandleFunc("POST /api/v1/auth/logout", a.handleAuthLogout)
|
mux.HandleFunc("POST /api/v1/auth/logout", a.handleAuthLogout)
|
||||||
mux.HandleFunc("GET /api/v1/auth/me", a.handleAuthMe)
|
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/register", a.handleNodeRegister)
|
||||||
mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat)
|
mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat)
|
||||||
mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports)
|
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) {
|
func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
currentUser, ok := a.requireSessionUser(w, r)
|
currentUser, ok := a.requireSessionUser(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -177,7 +187,7 @@ func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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) {
|
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)
|
http.Error(w, errExportNotFound.Error(), http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
context.node = a.presentedNode(context.node)
|
||||||
|
if !nodeIsConnected(context.node) {
|
||||||
|
http.Error(w, errMountTargetUnavailable.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
mountURL, err := buildMountURL(context)
|
mountURL, err := buildMountURL(context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,27 @@ func (s *sqliteStore) listExports(ownerID string) []storageExport {
|
||||||
return exports
|
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 {
|
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)
|
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 {
|
if err != nil {
|
||||||
|
|
@ -401,15 +422,29 @@ func (s *sqliteStore) exportContext(exportID string, ownerID string) (exportCont
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) {
|
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 n nasNode
|
||||||
var directAddr, relayAddr sql.NullString
|
var directAddr, relayAddr sql.NullString
|
||||||
var lastSeenAt sql.NullString
|
var lastSeenAt sql.NullString
|
||||||
var ownerID sql.NullString
|
var ownerID sql.NullString
|
||||||
err := s.db.QueryRow(
|
err := scanner.Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr)
|
||||||
"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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nasNode{}, false
|
return nasNode{}
|
||||||
}
|
}
|
||||||
if ownerID.Valid {
|
if ownerID.Valid {
|
||||||
n.OwnerID = ownerID.String
|
n.OwnerID = ownerID.String
|
||||||
|
|
@ -423,7 +458,7 @@ func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) {
|
||||||
if relayAddr.Valid {
|
if relayAddr.Valid {
|
||||||
n.RelayAddress = &relayAddr.String
|
n.RelayAddress = &relayAddr.String
|
||||||
}
|
}
|
||||||
return n, true
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) {
|
func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) {
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,25 @@ func (s *memoryStore) listExports(ownerID string) []storageExport {
|
||||||
return exports
|
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) {
|
func (s *memoryStore) exportContext(exportID string, ownerID string) (exportContext, bool) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ type store interface {
|
||||||
upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error)
|
upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error)
|
||||||
recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error
|
recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error
|
||||||
listExports(ownerID string) []storageExport
|
listExports(ownerID string) []storageExport
|
||||||
|
listNodes(ownerID string) []nasNode
|
||||||
exportContext(exportID string, ownerID string) (exportContext, bool)
|
exportContext(exportID string, ownerID string) (exportContext, bool)
|
||||||
nodeByID(nodeID string) (nasNode, bool)
|
nodeByID(nodeID string) (nasNode, bool)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/webdav"
|
"golang.org/x/net/webdav"
|
||||||
)
|
)
|
||||||
|
|
@ -27,6 +30,7 @@ type app struct {
|
||||||
authUsername string
|
authUsername string
|
||||||
authPassword string
|
authPassword string
|
||||||
exportMounts []exportMount
|
exportMounts []exportMount
|
||||||
|
controlPlane *controlPlaneSession
|
||||||
}
|
}
|
||||||
|
|
||||||
type exportMount struct {
|
type exportMount struct {
|
||||||
|
|
@ -69,17 +73,92 @@ func newAppFromEnv() (*app, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
var controlPlane *controlPlaneSession
|
||||||
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
|
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
|
||||||
if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil {
|
session, err := bootstrapNodeAgentFromEnv(exportPaths)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
controlPlane = &session
|
||||||
}
|
}
|
||||||
|
|
||||||
return newApp(appConfig{
|
app, err := newApp(appConfig{
|
||||||
exportPaths: exportPaths,
|
exportPaths: exportPaths,
|
||||||
authUsername: authUsername,
|
authUsername: authUsername,
|
||||||
authPassword: authPassword,
|
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) {
|
func exportPathsFromEnv() ([]string, error) {
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type bootstrapResult struct {
|
type controlPlaneSession struct {
|
||||||
nodeID string
|
nodeID string
|
||||||
|
controlPlaneURL string
|
||||||
|
sessionToken string
|
||||||
|
heartbeatInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type nodeRegistrationRequest struct {
|
type nodeRegistrationRequest struct {
|
||||||
|
|
@ -58,19 +61,23 @@ type nodeHeartbeatRequest struct {
|
||||||
LastSeenAt string `json:"lastSeenAt"`
|
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"))
|
controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com"))
|
||||||
if controlPlaneURL == "" {
|
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")
|
username, err := requiredEnv("BETTERNAS_USERNAME")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bootstrapResult{}, err
|
return controlPlaneSession{}, err
|
||||||
}
|
}
|
||||||
password, err := requiredEnv("BETTERNAS_PASSWORD")
|
password, err := requiredEnv("BETTERNAS_PASSWORD")
|
||||||
if err != nil {
|
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)))
|
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}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
|
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bootstrapResult{}, err
|
return controlPlaneSession{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
|
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
|
||||||
|
|
@ -93,17 +100,22 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
||||||
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
|
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bootstrapResult{}, err
|
return controlPlaneSession{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
|
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 {
|
if err := sendNodeHeartbeatAt(client, controlPlaneURL, sessionToken, registration.ID, time.Now().UTC()); err != nil {
|
||||||
return bootstrapResult{}, err
|
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) {
|
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 {
|
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{
|
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
Status: "online",
|
Status: "online",
|
||||||
LastSeenAt: time.Now().UTC().Format(time.RFC3339),
|
LastSeenAt: at.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,6 +17,19 @@ func main() {
|
||||||
log.Fatal(err)
|
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{
|
server := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
Handler: app.handler(),
|
Handler: app.handler(),
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ import {
|
||||||
import {
|
import {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
listExports,
|
listExports,
|
||||||
|
listNodes,
|
||||||
issueMountProfile,
|
issueMountProfile,
|
||||||
logout,
|
logout,
|
||||||
getMe,
|
getMe,
|
||||||
type StorageExport,
|
type StorageExport,
|
||||||
type MountProfile,
|
type MountProfile,
|
||||||
|
type NasNode,
|
||||||
type User,
|
type User,
|
||||||
ApiError,
|
ApiError,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
@ -38,6 +40,7 @@ import { CopyField } from "./copy-field";
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [nodes, setNodes] = useState<NasNode[]>([]);
|
||||||
const [exports, setExports] = useState<StorageExport[]>([]);
|
const [exports, setExports] = useState<StorageExport[]>([]);
|
||||||
const [selectedExportId, setSelectedExportId] = useState<string | null>(null);
|
const [selectedExportId, setSelectedExportId] = useState<string | null>(null);
|
||||||
const [mountProfile, setMountProfile] = useState<MountProfile | null>(null);
|
const [mountProfile, setMountProfile] = useState<MountProfile | null>(null);
|
||||||
|
|
@ -52,8 +55,13 @@ export default function Home() {
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [me, exps] = await Promise.all([getMe(), listExports()]);
|
const [me, registeredNodes, exps] = await Promise.all([
|
||||||
|
getMe(),
|
||||||
|
listNodes(),
|
||||||
|
listExports(),
|
||||||
|
]);
|
||||||
setUser(me);
|
setUser(me);
|
||||||
|
setNodes(registeredNodes);
|
||||||
setExports(exps);
|
setExports(exps);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 401) {
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
|
@ -100,6 +108,13 @@ export default function Home() {
|
||||||
const selectedExport = selectedExportId
|
const selectedExport = selectedExportId
|
||||||
? (exports.find((e) => e.id === selectedExportId) ?? null)
|
? (exports.find((e) => e.id === selectedExportId) ?? null)
|
||||||
: 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 (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<main className="min-h-screen bg-background">
|
||||||
|
|
@ -135,6 +150,9 @@ export default function Home() {
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
|
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{nodes.length === 1 ? "1 node" : `${nodes.length} nodes`}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
|
|
@ -172,12 +190,107 @@ export default function Home() {
|
||||||
</Alert>
|
</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]">
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_400px]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Exports</CardTitle>
|
<CardTitle>Exports</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Storage exports registered with this control plane.
|
Connected storage exports that are currently mountable.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardAction>
|
<CardAction>
|
||||||
<Badge variant="secondary">
|
<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">
|
<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" />
|
<HardDrives size={32} className="text-muted-foreground/40" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No exports registered yet. Start the node agent and connect
|
{nodes.length === 0
|
||||||
it to this control plane.
|
? "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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -371,3 +485,17 @@ export default function Home() {
|
||||||
</main>
|
</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 || "";
|
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 {
|
export interface StorageExport {
|
||||||
id: string;
|
id: string;
|
||||||
nasNodeId: string;
|
nasNodeId: string;
|
||||||
|
|
@ -146,6 +157,10 @@ export async function getMe(): Promise<User> {
|
||||||
return apiFetch<User>("/api/v1/auth/me");
|
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[]> {
|
export async function listExports(): Promise<StorageExport[]> {
|
||||||
return apiFetch<StorageExport[]>("/api/v1/exports");
|
return apiFetch<StorageExport[]>("/api/v1/exports");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,22 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Control-plane version
|
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:
|
/api/v1/nodes/register:
|
||||||
post:
|
post:
|
||||||
operationId: registerNode
|
operationId: registerNode
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export const FOUNDATION_API_ROUTES = {
|
export const FOUNDATION_API_ROUTES = {
|
||||||
|
listNodes: "/api/v1/nodes",
|
||||||
registerNode: "/api/v1/nodes/register",
|
registerNode: "/api/v1/nodes/register",
|
||||||
nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat",
|
nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat",
|
||||||
nodeExports: "/api/v1/nodes/:nodeId/exports",
|
nodeExports: "/api/v1/nodes/:nodeId/exports",
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ case "$arch_name" in
|
||||||
;;
|
;;
|
||||||
esac
|
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}"
|
download_url="${download_base_url}/${version}/${archive_name}"
|
||||||
|
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue