From 87de69520ca5aa0f329174bb07509eedbb3576b5 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 17:56:05 +0000 Subject: [PATCH] Make the web app consume the live mount contract Add the first control-plane UI over the verified backend seam so exports, issued DAV credentials, and Finder instructions can be exercised from the running stack. --- .prettierignore | 2 + apps/web/README.md | 8 + apps/web/app/copy-field.module.css | 42 +++++ apps/web/app/copy-field.tsx | 29 +++ apps/web/app/page.module.css | 262 +++++++++++++++++++++++++-- apps/web/app/page.tsx | 274 +++++++++++++++++++++++++---- apps/web/lib/control-plane.ts | 202 +++++++++++++++++++++ turbo.json | 5 + 8 files changed, 773 insertions(+), 51 deletions(-) create mode 100644 apps/web/app/copy-field.module.css create mode 100644 apps/web/app/copy-field.tsx create mode 100644 apps/web/lib/control-plane.ts diff --git a/.prettierignore b/.prettierignore index b658a0a..9742925 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,9 @@ .git +.agents .next .turbo coverage dist node_modules apps/web/.next +skills-lock.json diff --git a/apps/web/README.md b/apps/web/README.md index 28d6986..b8acc89 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -11,3 +11,11 @@ Use this app for: Do not move the product system of record into this app. It should stay a UI and thin BFF layer over the Go control plane. + +The current page reads control-plane config from: + +- `BETTERNAS_CONTROL_PLANE_URL` and `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, or +- the repo-local `.env.agent` file + +That keeps the page aligned with the running self-hosted stack during local +development. diff --git a/apps/web/app/copy-field.module.css b/apps/web/app/copy-field.module.css new file mode 100644 index 0000000..99101e8 --- /dev/null +++ b/apps/web/app/copy-field.module.css @@ -0,0 +1,42 @@ +.field { + display: grid; + gap: 10px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.label { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #527082; +} + +.button { + border: 0; + border-radius: 999px; + padding: 9px 12px; + background: #123043; + color: #f7fbfc; + font: inherit; + font-size: 0.86rem; + cursor: pointer; +} + +.value { + display: block; + overflow-wrap: anywhere; + padding: 12px 14px; + border-radius: 14px; + background: #f3f8f7; + border: 1px solid rgba(18, 48, 67, 0.08); + color: #123043; + font-size: 0.92rem; + line-height: 1.5; +} diff --git a/apps/web/app/copy-field.tsx b/apps/web/app/copy-field.tsx new file mode 100644 index 0000000..2f621ac --- /dev/null +++ b/apps/web/app/copy-field.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Button } from "@betternas/ui/button"; +import { Code } from "@betternas/ui/code"; +import { useState } from "react"; +import styles from "./copy-field.module.css"; + +export function CopyField({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false); + + return ( +
+
+ {label} + +
+ {value} +
+ ); +} diff --git a/apps/web/app/page.module.css b/apps/web/app/page.module.css index 3f2ae04..f0d38e3 100644 --- a/apps/web/app/page.module.css +++ b/apps/web/app/page.module.css @@ -12,7 +12,7 @@ } .hero { - max-width: 860px; + max-width: 1200px; margin: 0 auto 32px; padding: 32px; border-radius: 28px; @@ -42,32 +42,252 @@ line-height: 1.7; } -.grid { - max-width: 860px; +.heroMeta { + margin-top: 22px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} + +.heroMeta div { + padding: 16px; + border-radius: 18px; + background: rgba(247, 251, 252, 0.12); + border: 1px solid rgba(247, 251, 252, 0.14); +} + +.heroMeta dt { + margin: 0 0 8px; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.76; +} + +.heroMeta dd { + margin: 0; + font-size: 0.98rem; + line-height: 1.5; +} + +.layout { + max-width: 1200px; margin: 0 auto; display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 18px; + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); + gap: 22px; } -.card { - display: block; - padding: 22px; - border-radius: 20px; +.panel { + display: grid; + align-content: start; + gap: 18px; + padding: 24px; + border-radius: 28px; background: rgba(255, 255, 255, 0.88); + border: 1px solid rgba(18, 48, 67, 0.1); + box-shadow: 0 18px 50px rgba(18, 48, 67, 0.1); +} + +.panelHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.sectionEyebrow { + margin: 0; + font-size: 0.76rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #527082; +} + +.sectionTitle { + margin: 8px 0 0; + font-size: clamp(1.4rem, 2vw, 1.85rem); + color: #10212d; +} + +.sectionMeta { + align-self: center; + padding: 8px 12px; + border-radius: 999px; + background: #eff6f5; + color: #26485b; + font-size: 0.88rem; +} + +.notice { + padding: 14px 16px; + border-radius: 18px; + background: #fff1de; + color: #7a4a12; + line-height: 1.6; +} + +.emptyState { + padding: 22px; + border-radius: 22px; + background: #f3f8f7; + border: 1px dashed rgba(18, 48, 67, 0.16); + color: #395667; + line-height: 1.7; +} + +.exportList { + display: grid; + gap: 14px; +} + +.exportCard, +.exportCardSelected { + display: grid; + gap: 16px; + padding: 20px; + border-radius: 22px; border: 1px solid rgba(18, 48, 67, 0.08); - box-shadow: 0 12px 30px rgba(18, 48, 67, 0.08); + background: #fbfdfd; color: inherit; text-decoration: none; + transition: + transform 140ms ease, + box-shadow 140ms ease, + border-color 140ms ease; } -.card h2 { - margin: 0 0 10px; +.exportCard:hover, +.exportCardSelected:hover { + transform: translateY(-1px); + box-shadow: 0 12px 26px rgba(18, 48, 67, 0.08); } -.card p { +.exportCardSelected { + border-color: rgba(29, 84, 102, 0.28); + background: linear-gradient(180deg, #f4fbfa 0%, #eef8f6 100%); +} + +.exportCardTop { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.exportTitle { margin: 0; - line-height: 1.6; + font-size: 1.1rem; + color: #123043; +} + +.exportId { + margin: 6px 0 0; + color: #527082; + font-size: 0.92rem; +} + +.exportProtocol { + flex-shrink: 0; + padding: 8px 10px; + border-radius: 999px; + background: #eaf3f1; + color: #1d5466; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.exportFacts, +.mountMeta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.exportFactWide { + grid-column: 1 / -1; +} + +.exportFacts dt, +.mountMeta dt { + margin: 0 0 6px; + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5d7888; +} + +.exportFacts dd, +.mountMeta dd { + margin: 0; + overflow-wrap: anywhere; + color: #123043; + line-height: 1.55; +} + +.mountPanel { + display: grid; + gap: 20px; +} + +.mountStatus { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.mountTitle { + margin: 8px 0 0; + font-size: 1.3rem; + color: #123043; +} + +.mountBadge { + flex-shrink: 0; + padding: 8px 12px; + border-radius: 999px; + background: #123043; + color: #f7fbfc; + font-size: 0.84rem; +} + +.copyFields { + display: grid; + gap: 14px; +} + +.instructions { + padding: 18px 20px; + border-radius: 22px; + background: #f4f8fa; + border: 1px solid rgba(18, 48, 67, 0.08); +} + +.instructionsTitle { + margin: 0 0 12px; + color: #123043; + font-size: 1rem; +} + +.instructionsList { + padding-left: 20px; + color: #304e60; + line-height: 1.7; +} + +.inlineCode { + padding: 2px 6px; + border-radius: 8px; + background: rgba(18, 48, 67, 0.08); + color: #123043; +} + +@media (max-width: 980px) { + .layout { + grid-template-columns: 1fr; + } } @media (max-width: 640px) { @@ -75,7 +295,19 @@ padding-inline: 16px; } - .hero { + .hero, + .panel { padding: 24px; } + + .panelHeader, + .mountStatus, + .exportCardTop { + flex-direction: column; + } + + .exportFacts, + .mountMeta { + grid-template-columns: 1fr; + } } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index b474e5b..1cceabb 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,52 +1,254 @@ -import { Card } from "@betternas/ui/card"; +import { Code } from "@betternas/ui/code"; +import { CopyField } from "./copy-field"; +import { + ControlPlaneConfigurationError, + ControlPlaneRequestError, + getControlPlaneConfig, + issueMountProfile, + listExports, + type MountProfile, + type StorageExport, +} from "../lib/control-plane"; import styles from "./page.module.css"; -const lanes = [ - { - title: "NAS node", - body: "Runs on the storage machine. Exposes WebDAV, reports exports, and stays close to the bytes.", - }, - { - title: "Control plane", - body: "Owns users, devices, nodes, grants, mount profiles, and cloud profiles.", - }, - { - title: "Local device", - body: "Consumes mount profiles and uses Finder WebDAV flows before we ship a helper app.", - }, - { - title: "Cloud layer", - body: "Keeps Nextcloud optional and thin for browser, mobile, and sharing flows.", - }, -]; +export const dynamic = "force-dynamic"; + +interface PageProps { + searchParams: Promise<{ + exportId?: string | string[]; + }>; +} + +export default async function Home({ searchParams }: PageProps) { + const resolvedSearchParams = await searchParams; + const selectedExportId = readSearchParam(resolvedSearchParams.exportId); + const controlPlaneConfig = await getControlPlaneConfig(); + + let exports: StorageExport[] = []; + let mountProfile: MountProfile | null = null; + let feedback: string | null = null; + + try { + exports = await listExports(); + + if (selectedExportId !== null) { + if ( + exports.some((storageExport) => storageExport.id === selectedExportId) + ) { + mountProfile = await issueMountProfile(selectedExportId); + } else { + feedback = `Export ${selectedExportId} was not found in the current control-plane response.`; + } + } + } catch (error) { + if ( + error instanceof ControlPlaneConfigurationError || + error instanceof ControlPlaneRequestError + ) { + feedback = error.message; + } else { + throw error; + } + } + + const selectedExport = + selectedExportId === null + ? null + : (exports.find( + (storageExport) => storageExport.id === selectedExportId, + ) ?? null); -export default function Home() { return (
-

betterNAS monorepo

+

betterNAS control plane

- Contract-first scaffold for NAS mounts and cloud mode. + Mount exports from the live control-plane.

- The repo is organized so each system part can be built in parallel - without inventing new interfaces. The source of truth is the root - contract plus the shared contracts package. + This page reads the running control-plane, lists available exports, + and issues Finder-friendly WebDAV mount credentials for the export you + select.

+
+
+
Control-plane URL
+
{controlPlaneConfig.baseUrl ?? "Not configured"}
+
+
+
Auth mode
+
+ {controlPlaneConfig.clientToken === null + ? "Missing client token" + : "Server-side bearer token"} +
+
+
+
Exports discovered
+
{exports.length}
+
+
-
- {lanes.map((lane) => ( - - {lane.body} - - ))} +
+
+
+
+

Exports

+

+ Registered storage exports +

+
+ + {exports.length === 1 ? "1 export" : `${exports.length} exports`} + +
+ + {feedback !== null ? ( +
{feedback}
+ ) : null} + + {exports.length === 0 ? ( +
+ No exports are registered yet. Start the node agent and verify the + control-plane connection first. +
+ ) : ( + + )} +
+ +
); } + +function readSearchParam(value: string | string[] | undefined): string | null { + if (typeof value === "string" && value.trim() !== "") { + return value.trim(); + } + if (Array.isArray(value)) { + const firstValue = value.find( + (candidateValue) => candidateValue.trim() !== "", + ); + return firstValue?.trim() ?? null; + } + + return null; +} diff --git a/apps/web/lib/control-plane.ts b/apps/web/lib/control-plane.ts new file mode 100644 index 0000000..e5fe97c --- /dev/null +++ b/apps/web/lib/control-plane.ts @@ -0,0 +1,202 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { cache } from "react"; + +export interface StorageExport { + id: string; + nasNodeId: string; + label: string; + path: string; + mountPath?: string; + protocols: string[]; + capacityBytes: number | null; + tags: string[]; +} + +export interface MountCredential { + mode: "basic-auth"; + username: string; + password: string; + expiresAt: string; +} + +export interface MountProfile { + id: string; + exportId: string; + protocol: "webdav"; + displayName: string; + mountUrl: string; + readonly: boolean; + credential: MountCredential; +} + +export interface ControlPlaneConfig { + baseUrl: string | null; + clientToken: string | null; +} + +export class ControlPlaneConfigurationError extends Error { + constructor() { + super( + "Control-plane configuration is missing. Set BETTERNAS_CONTROL_PLANE_URL and BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN, or provide them through .env.agent.", + ); + } +} + +export class ControlPlaneRequestError extends Error { + constructor(message: string) { + super(message); + } +} + +const readAgentEnvFile = cache(async (): Promise> => { + const candidatePaths = [ + path.resolve(/* turbopackIgnore: true */ process.cwd(), ".env.agent"), + path.resolve(/* turbopackIgnore: true */ process.cwd(), "../../.env.agent"), + ]; + + for (const candidatePath of candidatePaths) { + try { + const raw = await readFile(candidatePath, "utf8"); + return parseEnvLikeFile(raw); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === "ENOENT") { + continue; + } + + throw error; + } + } + + return {}; +}); + +function parseEnvLikeFile(raw: string): Record { + return raw.split(/\r?\n/).reduce>((env, line) => { + const trimmedLine = line.trim(); + if (trimmedLine === "" || trimmedLine.startsWith("#")) { + return env; + } + + const separatorIndex = trimmedLine.indexOf("="); + if (separatorIndex === -1) { + return env; + } + + const key = trimmedLine.slice(0, separatorIndex).trim(); + const value = trimmedLine.slice(separatorIndex + 1).trim(); + env[key] = unwrapEnvValue(value); + return env; + }, {}); +} + +function unwrapEnvValue(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + + return value; +} + +export async function getControlPlaneConfig(): Promise { + const agentEnv = await readAgentEnvFile(); + const cloneName = firstDefinedValue( + process.env.BETTERNAS_CLONE_NAME, + agentEnv.BETTERNAS_CLONE_NAME, + ); + + const clientToken = firstDefinedValue( + process.env.BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN, + agentEnv.BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN, + cloneName === null ? undefined : `${cloneName}-local-client-token`, + ); + + const directBaseUrl = firstDefinedValue( + process.env.BETTERNAS_CONTROL_PLANE_URL, + agentEnv.BETTERNAS_CONTROL_PLANE_URL, + ); + if (directBaseUrl !== null) { + return { + baseUrl: trimTrailingSlash(directBaseUrl), + clientToken, + }; + } + + const controlPlanePort = firstDefinedValue( + process.env.BETTERNAS_CONTROL_PLANE_PORT, + agentEnv.BETTERNAS_CONTROL_PLANE_PORT, + ); + + return { + baseUrl: + controlPlanePort === null + ? null + : trimTrailingSlash(`http://localhost:${controlPlanePort}`), + clientToken, + }; +} + +export async function listExports(): Promise { + return controlPlaneRequest("/api/v1/exports"); +} + +export async function issueMountProfile( + exportId: string, +): Promise { + return controlPlaneRequest("/api/v1/mount-profiles/issue", { + method: "POST", + body: JSON.stringify({ exportId }), + }); +} + +async function controlPlaneRequest( + requestPath: string, + init?: RequestInit, +): Promise { + const config = await getControlPlaneConfig(); + if (config.baseUrl === null || config.clientToken === null) { + throw new ControlPlaneConfigurationError(); + } + + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${config.clientToken}`); + if (init?.body !== undefined) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(`${config.baseUrl}${requestPath}`, { + ...init, + headers, + cache: "no-store", + }); + + if (!response.ok) { + const responseBody = await response.text(); + throw new ControlPlaneRequestError( + `Control-plane request failed for ${requestPath} with status ${response.status}: ${responseBody || response.statusText}`, + ); + } + + return (await response.json()) as T; +} + +function firstDefinedValue( + ...values: Array +): string | null { + for (const value of values) { + const trimmedValue = value?.trim(); + if (trimmedValue) { + return trimmedValue; + } + } + + return null; +} + +function trimTrailingSlash(value: string): string { + return value.replace(/\/+$/, ""); +} diff --git a/turbo.json b/turbo.json index a223f00..fe81974 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,11 @@ { "$schema": "https://turborepo.dev/schema.json", "ui": "tui", + "globalEnv": [ + "BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN", + "BETTERNAS_CONTROL_PLANE_PORT", + "BETTERNAS_CONTROL_PLANE_URL" + ], "tasks": { "build": { "dependsOn": ["^build"],