mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-17 08:00:59 +00:00
web repo
This commit is contained in:
parent
f6069a024a
commit
43ef276976
7 changed files with 508 additions and 708 deletions
|
|
@ -65,8 +65,17 @@
|
||||||
## 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.
|
||||||
|
- When setting up a separate machine as a node in this session, access it through `computer ssh hiromi`.
|
||||||
|
|
||||||
## Node availability UX
|
## Node availability UX
|
||||||
|
|
||||||
- Prefer default UI behavior that does not present disconnected nodes as mountable.
|
- 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.
|
- Surface connected and disconnected node state in the product when node availability is exposed.
|
||||||
|
|
||||||
|
## Product docs UX
|
||||||
|
|
||||||
|
- Remove operational setup instructions from the main control-plane page when they are better represented as dedicated end-to-end product docs.
|
||||||
|
- Prefer a separate clean docs page for simple end-to-end usage instructions.
|
||||||
|
- Keep the root `README.md` extremely minimal: no headings, just 5-6 plain lines that explain the architecture simply and cleanly.
|
||||||
|
- Make the default root route land on the main landing page instead of the auth-gated app.
|
||||||
|
- Add a plain `Docs` button to the left of the sign-in button on the main landing page.
|
||||||
|
|
|
||||||
120
README.md
120
README.md
|
|
@ -1,114 +1,6 @@
|
||||||
# betterNAS
|
betterNAS is a hosted control plane with a user-run node agent.
|
||||||
|
The control plane owns user auth, node enrollment, heartbeats, export state, and mount issuance.
|
||||||
betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.
|
The node agent runs on the machine that owns the files and serves them over WebDAV.
|
||||||
|
The web app reads from the control plane and shows nodes, exports, and mount details.
|
||||||
The default product shape is:
|
Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password.
|
||||||
|
File traffic goes directly between the client and the node, not through the control plane.
|
||||||
- `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
|
|
||||||
|
|
||||||
For now, the whole stack should be able to run on the user's NAS device.
|
|
||||||
|
|
||||||
## Current repo shape
|
|
||||||
|
|
||||||
- `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
|
|
||||||
|
|
||||||
The main planning docs are:
|
|
||||||
|
|
||||||
- [docs/architecture.md](./docs/architecture.md)
|
|
||||||
- [skeleton.md](./skeleton.md)
|
|
||||||
- [docs/05-build-plan.md](./docs/05-build-plan.md)
|
|
||||||
|
|
||||||
## Default runtime model
|
|
||||||
|
|
||||||
```text
|
|
||||||
self-hosted betterNAS on the user's NAS
|
|
||||||
|
|
||||||
+------------------------------+
|
|
||||||
| web control plane |
|
|
||||||
| Next.js UI |
|
|
||||||
+--------------+---------------+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
+------------------------------+
|
|
||||||
| control-server |
|
|
||||||
| auth / nodes / exports |
|
|
||||||
| grants / mount profiles |
|
|
||||||
+--------------+---------------+
|
|
||||||
|
|
|
||||||
v
|
|
||||||
+------------------------------+
|
|
||||||
| node-service |
|
|
||||||
| WebDAV + export runtime |
|
|
||||||
| real NAS bytes |
|
|
||||||
+------------------------------+
|
|
||||||
|
|
||||||
user Mac
|
|
||||||
|
|
|
||||||
+--> browser -> web control plane
|
|
||||||
|
|
|
||||||
+--> Finder -> WebDAV mount URL from control-server
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
Static verification:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm verify
|
|
||||||
```
|
|
||||||
|
|
||||||
Bootstrap clone-local runtime settings:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm agent:bootstrap
|
|
||||||
```
|
|
||||||
|
|
||||||
Bring the self-hosted stack up, verify it, and tear it down:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm stack:up
|
|
||||||
pnpm stack:verify
|
|
||||||
pnpm stack:down --volumes
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the full loop:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm agent:verify
|
|
||||||
```
|
|
||||||
|
|
||||||
## Current end-to-end slice
|
|
||||||
|
|
||||||
The first proven slice is:
|
|
||||||
|
|
||||||
1. boot the stack with `pnpm stack:up`
|
|
||||||
2. verify it with `pnpm stack:verify`
|
|
||||||
3. get the WebDAV mount profile from the control plane
|
|
||||||
4. mount it in Finder with the issued credentials
|
|
||||||
|
|
||||||
If the stack is running on a remote machine, tunnel the WebDAV port first, then
|
|
||||||
use Finder `Connect to Server` with the tunneled URL.
|
|
||||||
|
|
||||||
## Product boundary
|
|
||||||
|
|
||||||
The default betterNAS product is self-hosted and WebDAV-first.
|
|
||||||
|
|
||||||
Nextcloud remains optional and secondary:
|
|
||||||
|
|
||||||
- useful later for browser/mobile/share surfaces
|
|
||||||
- not required for the core mount flow
|
|
||||||
- not the system of record
|
|
||||||
|
|
|
||||||
283
apps/web/app/app/page.tsx
Normal file
283
apps/web/app/app/page.tsx
Normal 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 > 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
181
apps/web/app/docs/page.tsx
Normal 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 > 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,50 +11,27 @@ const README_LINES = [
|
||||||
{ tag: "h1", text: "betterNAS" },
|
{ tag: "h1", text: "betterNAS" },
|
||||||
{
|
{
|
||||||
tag: "p",
|
tag: "p",
|
||||||
text: "betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.",
|
text: "betterNAS is a hosted control plane with a user-run node agent.",
|
||||||
},
|
|
||||||
{ 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",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "p",
|
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",
|
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;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -217,59 +194,11 @@ function ReadmeModal({ onClose }: { onClose: () => void }) {
|
||||||
{block.text}
|
{block.text}
|
||||||
</h1>
|
</h1>
|
||||||
);
|
);
|
||||||
if (block.tag === "h2")
|
return (
|
||||||
return (
|
<p key={i} className="text-sm leading-relaxed text-muted-foreground">
|
||||||
<h2
|
{block.text}
|
||||||
key={i}
|
</p>
|
||||||
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;
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -394,6 +323,12 @@ export default function LandingPage() {
|
||||||
{/* ---- header ---- */}
|
{/* ---- header ---- */}
|
||||||
<header className="flex shrink-0 items-center justify-end px-5 py-3.5">
|
<header className="flex shrink-0 items-center justify-end px-5 py-3.5">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Link
|
||||||
href="/login"
|
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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default function LoginPage() {
|
||||||
} else {
|
} else {
|
||||||
await register(username, password);
|
await register(username, password);
|
||||||
}
|
}
|
||||||
router.push("/");
|
router.push("/app");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message);
|
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">
|
<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">
|
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/landing"
|
href="/"
|
||||||
className="inline-flex items-center gap-1.5 self-start text-sm text-muted-foreground transition-colors hover:text-foreground"
|
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">
|
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
|
|
||||||
|
|
@ -1,501 +1 @@
|
||||||
"use client";
|
export { default } from "./landing/page";
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue