This commit is contained in:
Harivansh Rathi 2026-04-01 22:10:39 -04:00
parent f6069a024a
commit 43ef276976
7 changed files with 508 additions and 708 deletions

283
apps/web/app/app/page.tsx Normal file
View file

@ -0,0 +1,283 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SignOut } from "@phosphor-icons/react";
import {
isAuthenticated,
listExports,
listNodes,
issueMountProfile,
logout,
getMe,
type StorageExport,
type MountProfile,
type NasNode,
type User,
ApiError,
} from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
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);
const [feedback, setFeedback] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!isAuthenticated()) {
router.replace("/login");
return;
}
async function load() {
try {
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) {
router.replace("/login");
return;
}
setFeedback(err instanceof Error ? err.message : "Failed to load");
} finally {
setLoading(false);
}
}
load();
}, [router]);
async function handleSelectExport(exportId: string) {
setSelectedExportId(exportId);
setMountProfile(null);
setFeedback(null);
try {
const profile = await issueMountProfile(exportId);
setMountProfile(profile);
} catch (err) {
setFeedback(
err instanceof Error ? err.message : "Failed to issue mount profile",
);
}
}
async function handleLogout() {
await logout();
router.replace("/login");
}
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center bg-background">
<p className="text-sm text-muted-foreground">Loading...</p>
</main>
);
}
const selectedExport = selectedExportId
? (exports.find((e) => e.id === selectedExportId) ?? null)
: null;
return (
<main className="min-h-screen bg-background">
<div className="mx-auto flex max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6">
{/* header */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<Link
href="/"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
betterNAS
</Link>
<h1 className="text-xl font-semibold tracking-tight">
Control Plane
</h1>
</div>
{user && (
<div className="flex items-center gap-3">
<Link
href="/docs"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Docs
</Link>
<span className="text-sm text-muted-foreground">
{user.username}
</span>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<SignOut className="mr-1 size-4" />
Sign out
</Button>
</div>
)}
</div>
{feedback !== null && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{feedback}
</div>
)}
{/* nodes */}
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">Nodes</h2>
<span className="text-xs text-muted-foreground">
{nodes.filter((n) => n.status === "online").length} online
{nodes.filter((n) => n.status === "offline").length > 0 &&
`, ${nodes.filter((n) => n.status === "offline").length} offline`}
</span>
</div>
{nodes.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No nodes registered yet. Install and start the node agent on the
machine that owns your files.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{nodes.map((node) => (
<div
key={node.id}
className="flex items-start justify-between gap-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-1 overflow-hidden">
<span className="text-sm font-medium">
{node.displayName}
</span>
<span className="truncate text-xs text-muted-foreground">
{node.directAddress ?? node.relayAddress ?? node.machineId}
</span>
<span className="text-xs text-muted-foreground">
Last seen {formatTimestamp(node.lastSeenAt)}
</span>
</div>
<Badge
variant={node.status === "offline" ? "outline" : "secondary"}
className={cn(
"shrink-0",
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",
)}
>
{node.status}
</Badge>
</div>
))}
</div>
)}
</section>
{/* exports + mount */}
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
{/* exports list */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium">Exports</h2>
{exports.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{nodes.length === 0
? "No exports yet. Start the node agent to register one."
: "No connected exports. Start the node agent or wait for reconnection."}
</p>
) : (
<div className="flex flex-col gap-2">
{exports.map((exp) => {
const isSelected = exp.id === selectedExportId;
return (
<button
key={exp.id}
onClick={() => handleSelectExport(exp.id)}
className={cn(
"flex items-start justify-between gap-4 rounded-lg border p-4 text-left text-sm transition-colors",
isSelected
? "border-foreground/20 bg-muted/50"
: "hover:bg-muted/30",
)}
>
<div className="flex flex-col gap-1 overflow-hidden">
<span className="font-medium">{exp.label}</span>
<span className="truncate text-xs text-muted-foreground">
{exp.path}
</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{exp.protocols.join(", ")}
</span>
</button>
);
})}
</div>
)}
</section>
{/* mount profile */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium">
{selectedExport ? `Mount ${selectedExport.label}` : "Mount"}
</h2>
{mountProfile === null ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Select an export to see the mount URL and credentials.
</p>
) : (
<div className="flex flex-col gap-5">
<CopyField label="Mount URL" value={mountProfile.mountUrl} />
<CopyField
label="Username"
value={mountProfile.credential.username}
/>
<p className="rounded-lg border bg-muted/20 px-3 py-2.5 text-xs leading-relaxed text-muted-foreground">
Use your betterNAS account password when Finder prompts. v1
does not issue a separate WebDAV password.
</p>
<div className="flex flex-col gap-1.5">
<h3 className="text-xs font-medium">Finder steps</h3>
<ol className="flex flex-col gap-1 text-xs text-muted-foreground">
<li>1. Go &gt; Connect to Server in Finder.</li>
<li>2. Paste the mount URL.</li>
<li>3. Enter your betterNAS username and password.</li>
<li>4. Optionally save to Keychain.</li>
</ol>
</div>
</div>
)}
</section>
</div>
</div>
</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();
}

181
apps/web/app/docs/page.tsx Normal file
View file

@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Check, Copy } from "@phosphor-icons/react";
function CodeBlock({ children, label }: { children: string; label?: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="group relative">
{label && (
<span className="mb-1.5 block text-xs text-muted-foreground">
{label}
</span>
)}
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-4 pr-12 font-mono text-xs leading-relaxed text-foreground">
<code>{children}</code>
</pre>
<button
onClick={async () => {
await navigator.clipboard.writeText(children);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}}
className="absolute right-2 top-2 flex items-center gap-1 rounded-md border bg-background/80 px-2 py-1 text-xs text-muted-foreground opacity-0 backdrop-blur transition-opacity hover:text-foreground group-hover:opacity-100"
aria-label="Copy to clipboard"
>
{copied ? (
<>
<Check size={12} weight="bold" /> Copied
</>
) : (
<>
<Copy size={12} /> Copy
</>
)}
</button>
</div>
);
}
export default function DocsPage() {
return (
<main className="min-h-screen bg-background">
<div className="mx-auto flex max-w-2xl flex-col gap-10 px-4 py-12 sm:px-6">
{/* header */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3L5 8l5 5" />
</svg>
</Link>
<Link
href="/login"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Sign in
</Link>
</div>
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
Getting started
</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
One account works everywhere: the web app, the node agent, and
Finder. Set up the node, confirm it is online, then mount your
export.
</p>
</div>
</div>
{/* prerequisites */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">Prerequisites</h2>
<ul className="flex flex-col gap-1.5 text-sm text-muted-foreground">
<li>- A betterNAS account</li>
<li>- A machine with the files you want to expose</li>
<li>- An export folder on that machine</li>
<li>
- A public HTTPS URL that reaches your node directly (for Finder
mounting)
</li>
</ul>
</section>
{/* step 1 */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">1. Install the node binary</h2>
<p className="text-sm text-muted-foreground">
Run this on the machine that owns the files.
</p>
<CodeBlock>
{`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
</CodeBlock>
</section>
{/* step 2 */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">2. Start the node</h2>
<p className="text-sm text-muted-foreground">
Replace the placeholders with your account, export path, and public
node URL.
</p>
<CodeBlock>
{`BETTERNAS_CONTROL_PLANE_URL=https://api.betternas.com \\
BETTERNAS_USERNAME=your-username \\
BETTERNAS_PASSWORD='your-password' \\
BETTERNAS_EXPORT_PATH=/absolute/path/to/export \\
BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url \\
betternas-node`}
</CodeBlock>
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
<p>
<span className="font-medium text-foreground">Export path</span>{" "}
- the directory you want to expose through betterNAS.
</p>
<p>
<span className="font-medium text-foreground">
Direct address
</span>{" "}
- the real public HTTPS base URL that reaches your node directly.
</p>
</div>
</section>
{/* step 3 */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">3. Confirm the node is online</h2>
<p className="text-sm text-muted-foreground">
Open the control plane after the node starts. You should see:
</p>
<ul className="flex flex-col gap-1.5 text-sm text-muted-foreground">
<li>- Your node appears as online</li>
<li>- Your export appears in the exports list</li>
<li>
- Issuing a mount profile gives you a WebDAV URL, not an HTML
login page
</li>
</ul>
</section>
{/* step 4 */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">4. Mount in Finder</h2>
<ol className="flex flex-col gap-1.5 text-sm text-muted-foreground">
<li>1. Open Finder, then Go &gt; Connect to Server.</li>
<li>
2. Copy the mount URL from the control plane and paste it in.
</li>
<li>
3. Sign in with the same username and password you used for the
web app and node agent.
</li>
<li>
4. Save to Keychain only if you want Finder to remember the
password.
</li>
</ol>
</section>
{/* note about public urls */}
<section className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 px-4 py-3">
<h2 className="text-sm font-medium">A note on public URLs</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
Finder mounting only works when the node URL is directly reachable
over HTTPS. Avoid gateways that show their own login page before
forwarding traffic. A good check: load{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">/dav/</code>{" "}
on your node URL. A working node responds with WebDAV headers, not
HTML.
</p>
</section>
</div>
</main>
);
}

View file

@ -11,50 +11,27 @@ const README_LINES = [
{ tag: "h1", text: "betterNAS" },
{
tag: "p",
text: "betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.",
},
{ tag: "p", text: "The default product shape is:" },
{
tag: "ul",
items: [
"node-service serves the real files from the NAS over WebDAV",
"control-server owns auth, nodes, exports, grants, and mount profile issuance",
"web control plane lets the user manage the NAS and get mount instructions",
"macOS client starts as native Finder WebDAV mounting, with a thin helper later",
],
text: "betterNAS is a hosted control plane with a user-run node agent.",
},
{
tag: "p",
text: "For now, the whole stack should be able to run on the user's NAS device.",
text: "The control plane owns user auth, node enrollment, heartbeats, export state, and mount issuance.",
},
{ tag: "h2", text: "Current repo shape" },
{
tag: "ul",
items: [
"apps/node-agent - NAS-side Go runtime and WebDAV server",
"apps/control-plane - Go backend for auth, registry, and mount profile issuance",
"apps/web - Next.js web control plane",
"apps/nextcloud-app - optional Nextcloud adapter, not the product center",
"packages/contracts - canonical shared contracts",
"infra/docker - self-hosted local stack",
],
},
{ tag: "h2", text: "Verify" },
{ tag: "code", text: "pnpm verify" },
{ tag: "h2", text: "Current end-to-end slice" },
{
tag: "ol",
items: [
"Boot the stack with pnpm stack:up",
"Verify it with pnpm stack:verify",
"Get the WebDAV mount profile from the control plane",
"Mount it in Finder with the issued credentials",
],
},
{ tag: "h2", text: "Product boundary" },
{
tag: "p",
text: "The default betterNAS product is self-hosted and WebDAV-first. Nextcloud remains optional and secondary.",
text: "The node agent runs on the machine that owns the files and serves them over WebDAV.",
},
{
tag: "p",
text: "The web app reads from the control plane and shows nodes, exports, and mount details.",
},
{
tag: "p",
text: "Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password.",
},
{
tag: "p",
text: "File traffic goes directly between the client and the node, not through the control plane.",
},
] as const;
@ -217,59 +194,11 @@ function ReadmeModal({ onClose }: { onClose: () => void }) {
{block.text}
</h1>
);
if (block.tag === "h2")
return (
<h2
key={i}
className="mt-6 border-b border-border pb-1 text-lg font-semibold text-foreground"
>
{block.text}
</h2>
);
if (block.tag === "p")
return (
<p key={i} className="text-sm leading-relaxed text-muted-foreground">
{block.text}
</p>
);
if (block.tag === "code")
return (
<pre
key={i}
className="rounded-lg border border-border bg-muted/40 px-4 py-3 font-mono text-xs text-foreground"
>
{block.text}
</pre>
);
if (block.tag === "ul")
return (
<ul key={i} className="space-y-1 pl-5 text-sm text-muted-foreground">
{block.items.map((item, j) => (
<li key={j} className="list-disc">
<code className="rounded bg-muted/60 px-1 py-0.5 text-xs text-foreground">
{item.split(" - ")[0]}
</code>
{item.includes(" - ") && (
<span className="text-muted-foreground">
{" "}
- {item.split(" - ").slice(1).join(" - ")}
</span>
)}
</li>
))}
</ul>
);
if (block.tag === "ol")
return (
<ol key={i} className="space-y-1 pl-5 text-sm text-muted-foreground">
{block.items.map((item, j) => (
<li key={j} className="list-decimal">
{item}
</li>
))}
</ol>
);
return null;
return (
<p key={i} className="text-sm leading-relaxed text-muted-foreground">
{block.text}
</p>
);
})}
</div>
</div>
@ -394,6 +323,12 @@ export default function LandingPage() {
{/* ---- header ---- */}
<header className="flex shrink-0 items-center justify-end px-5 py-3.5">
<div className="flex items-center gap-2">
<Link
href="/docs"
className="rounded-xl border border-border bg-muted/30 px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Docs
</Link>
<Link
href="/login"
className="rounded-xl border border-border bg-muted/30 px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"

View file

@ -32,7 +32,7 @@ export default function LoginPage() {
} else {
await register(username, password);
}
router.push("/");
router.push("/app");
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
@ -48,7 +48,7 @@ export default function LoginPage() {
<main className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="flex w-full max-w-sm flex-col gap-4">
<Link
href="/landing"
href="/"
className="inline-flex items-center gap-1.5 self-start text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">

View file

@ -1,501 +1 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Globe,
HardDrives,
LinkSimple,
SignOut,
Warning,
} from "@phosphor-icons/react";
import {
isAuthenticated,
listExports,
listNodes,
issueMountProfile,
logout,
getMe,
type StorageExport,
type MountProfile,
type NasNode,
type User,
ApiError,
} from "@/lib/api";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
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);
const [feedback, setFeedback] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!isAuthenticated()) {
router.replace("/login");
return;
}
async function load() {
try {
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) {
router.replace("/login");
return;
}
setFeedback(err instanceof Error ? err.message : "Failed to load");
} finally {
setLoading(false);
}
}
load();
}, [router]);
async function handleSelectExport(exportId: string) {
setSelectedExportId(exportId);
setMountProfile(null);
setFeedback(null);
try {
const profile = await issueMountProfile(exportId);
setMountProfile(profile);
} catch (err) {
setFeedback(
err instanceof Error ? err.message : "Failed to issue mount profile",
);
}
}
async function handleLogout() {
await logout();
router.replace("/login");
}
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center bg-background">
<p className="text-sm text-muted-foreground">Loading...</p>
</main>
);
}
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">
<div className="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-1">
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
betterNAS
</p>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Control Plane
</h1>
</div>
{user && (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{user.username}
</span>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<SignOut className="mr-1 size-4" />
Sign out
</Button>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
<Globe data-icon="inline-start" />
{process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"}
</Badge>
<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 && (
<Card>
<CardHeader>
<CardTitle>Node agent setup</CardTitle>
<CardDescription>
Run the node binary on the machine that owns the files with
the same account credentials you use here and in Finder.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<pre className="overflow-x-auto rounded-xl border bg-muted/40 p-4 text-xs text-foreground">
<code>
{`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
</code>
</pre>
<pre className="overflow-x-auto rounded-xl border bg-muted/40 p-4 text-xs text-foreground">
<code>
{`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url betternas-node`}
</code>
</pre>
</div>
</CardContent>
</Card>
)}
</div>
{feedback !== null && (
<Alert variant="destructive">
<Warning />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{feedback}</AlertDescription>
</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>
Connected storage exports that are currently mountable.
</CardDescription>
<CardAction>
<Badge variant="secondary">
{exports.length === 1
? "1 export"
: `${exports.length} exports`}
</Badge>
</CardAction>
</CardHeader>
<CardContent>
{exports.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">
{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>
) : (
<div className="flex flex-col gap-2">
{exports.map((storageExport) => {
const isSelected = storageExport.id === selectedExportId;
return (
<button
key={storageExport.id}
onClick={() => handleSelectExport(storageExport.id)}
className={cn(
"flex flex-col gap-3 rounded-2xl border p-4 text-left text-sm transition-colors",
isSelected
? "border-primary/20 bg-primary/5"
: "border-border hover:bg-muted/50",
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">
{storageExport.label}
</span>
<span className="text-xs text-muted-foreground">
{storageExport.id}
</span>
</div>
<Badge variant="secondary" className="shrink-0">
{storageExport.protocols.join(", ")}
</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
</dt>
<dd className="truncate text-xs text-foreground">
{storageExport.nasNodeId}
</dd>
</div>
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Mount path
</dt>
<dd className="text-xs text-foreground">
{storageExport.mountPath ?? "/dav/"}
</dd>
</div>
<div className="col-span-2">
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Export path
</dt>
<dd className="truncate text-xs text-foreground">
{storageExport.path}
</dd>
</div>
</dl>
</button>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
{selectedExport !== null
? `Mount ${selectedExport.label}`
: "Mount instructions"}
</CardTitle>
<CardDescription>
{selectedExport !== null
? "WebDAV mount details for Finder."
: "Select an export to see the mount URL and account login details."}
</CardDescription>
</CardHeader>
<CardContent>
{mountProfile === null ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
<LinkSimple size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
Pick an export to see the Finder mount URL and the username
to use with your betterNAS account password.
</p>
</div>
) : (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Issued profile
</span>
<Badge
variant={mountProfile.readonly ? "secondary" : "default"}
>
{mountProfile.readonly ? "Read-only" : "Read-write"}
</Badge>
</div>
<Separator />
<div className="flex flex-col gap-4">
<CopyField
label="Mount URL"
value={mountProfile.mountUrl}
/>
<CopyField
label="Username"
value={mountProfile.credential.username}
/>
<Alert>
<AlertTitle>
Use your betterNAS account password
</AlertTitle>
<AlertDescription>
Enter the same password you use to sign in to betterNAS
and run the node agent. v1 does not issue a separate
WebDAV password.
</AlertDescription>
</Alert>
</div>
<Separator />
<dl className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Mode
</dt>
<dd className="text-xs text-foreground">
{mountProfile.credential.mode}
</dd>
</div>
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Password source
</dt>
<dd className="text-xs text-foreground">
Your betterNAS account password
</dd>
</div>
</dl>
<Separator />
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Finder steps</h3>
<ol className="flex flex-col gap-2">
{[
"Open Finder and choose Go, then Connect to Server.",
"Paste the mount URL into the server address field.",
"Enter your betterNAS username and account password when prompted.",
"Save to Keychain only if you want Finder to reuse that same account password.",
].map((step, index) => (
<li
key={index}
className="flex gap-2.5 text-sm text-muted-foreground"
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
{index + 1}
</span>
{step}
</li>
))}
</ol>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</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();
}
export { default } from "./landing/page";