mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 07:04:44 +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
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
## 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 self-hostable WebDAV stack for mounting NAS exports in Finder.
|
||||
|
||||
The default product shape is:
|
||||
|
||||
- `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
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
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: "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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue