Fix install script: strip v prefix from version for archive name

This commit is contained in:
Harivansh Rathi 2026-04-01 21:06:40 -04:00
parent 8002158a45
commit 1d564b738d
16 changed files with 552 additions and 26 deletions

View file

@ -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.

View file

@ -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"`

View file

@ -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", ""),
},

View file

@ -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 {

View file

@ -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) {

View file

@ -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()

View file

@ -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)

View file

@ -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) {

View file

@ -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

View 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)
}
}
}

View file

@ -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(),

View file

@ -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();
}

View file

@ -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");
}

View file

@ -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

View file

@ -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",

View file

@ -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)"