diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index f5fb948..6d24507 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -47,7 +47,7 @@ jobs:
else
CHANGED=$(git diff --name-only HEAD~1)
fi
- for app in web api harness; do
+ for app in web api harness platform-dash; do
if echo "$CHANGED" | grep -qE "^(apps/${app}/|packages/)"; then
echo "Building $app..."
pnpm turbo build --filter=@homelab/${app}
@@ -71,7 +71,7 @@ jobs:
CHANGED=$(git diff --name-only HEAD~1)
fi
HAS_IMAGES=false
- for app in web api harness; do
+ for app in web api harness platform-dash; do
if echo "$CHANGED" | grep -qE "^(apps/${app}/|packages/)"; then
HAS_IMAGES=true
break
@@ -79,7 +79,7 @@ jobs:
done
if [ "$HAS_IMAGES" = "true" ]; then
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.coreworlds.io -u lazorgurl --password-stdin
- for app in web api harness; do
+ for app in web api harness platform-dash; do
if echo "$CHANGED" | grep -qE "^(apps/${app}/|packages/)"; then
docker push gitea.coreworlds.io/lazorgurl/homelab-${app}:${{ gitea.sha }}
fi
diff --git a/.gitea/workflows/deploy-production.yaml b/.gitea/workflows/deploy-production.yaml
index 0e2dcec..ea7fb6b 100644
--- a/.gitea/workflows/deploy-production.yaml
+++ b/.gitea/workflows/deploy-production.yaml
@@ -31,7 +31,7 @@ jobs:
if [ "${{ gitea.event_name }}" = "workflow_dispatch" ]; then
INPUT="${{ gitea.event.inputs.apps }}"
if [ -z "$INPUT" ]; then
- APPS="web,api,harness"
+ APPS="web,api,harness,platform-dash"
else
APPS="$INPUT"
fi
diff --git a/apps/platform-dash/.dockercontext b/apps/platform-dash/.dockercontext
new file mode 100644
index 0000000..d8649da
--- /dev/null
+++ b/apps/platform-dash/.dockercontext
@@ -0,0 +1 @@
+root
diff --git a/apps/platform-dash/Dockerfile b/apps/platform-dash/Dockerfile
new file mode 100644
index 0000000..97de163
--- /dev/null
+++ b/apps/platform-dash/Dockerfile
@@ -0,0 +1,30 @@
+FROM node:20-alpine AS base
+RUN corepack enable && corepack prepare pnpm@latest --activate
+
+FROM base AS deps
+WORKDIR /app
+COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
+COPY apps/platform-dash/package.json ./apps/platform-dash/package.json
+RUN pnpm install --frozen-lockfile --filter @homelab/platform-dash...
+
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app ./
+COPY apps/platform-dash ./apps/platform-dash
+RUN pnpm --filter @homelab/platform-dash build
+
+FROM base AS runner
+WORKDIR /app
+ENV NODE_ENV=production
+
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/apps/platform-dash/public ./apps/platform-dash/public
+COPY --from=builder --chown=nextjs:nodejs /app/apps/platform-dash/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/apps/platform-dash/.next/static ./apps/platform-dash/.next/static
+
+USER nextjs
+EXPOSE 3200
+ENV PORT=3200
+CMD ["node", "apps/platform-dash/server.js"]
diff --git a/apps/platform-dash/k8s/base/deployment.yaml b/apps/platform-dash/k8s/base/deployment.yaml
new file mode 100644
index 0000000..07480ff
--- /dev/null
+++ b/apps/platform-dash/k8s/base/deployment.yaml
@@ -0,0 +1,59 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: platform-dash
+ namespace: apps
+ labels:
+ app: platform-dash
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: platform-dash
+ template:
+ metadata:
+ labels:
+ app: platform-dash
+ spec:
+ imagePullSecrets:
+ - name: gitea-pull-secret
+ containers:
+ - name: platform-dash
+ image: gitea.coreworlds.io/lazorgurl/homelab-platform-dash:latest
+ ports:
+ - containerPort: 3200
+ env:
+ - name: S3_ENDPOINT
+ value: "http://garage.platform.svc:3900"
+ - name: S3_REGION
+ value: "garage"
+ - name: S3_BUCKET
+ value: "artifacts"
+ - name: S3_ACCESS_KEY_ID
+ valueFrom:
+ secretKeyRef:
+ name: platform-dash-s3
+ key: access-key-id
+ - name: S3_SECRET_ACCESS_KEY
+ valueFrom:
+ secretKeyRef:
+ name: platform-dash-s3
+ key: secret-access-key
+ resources:
+ requests:
+ memory: 128Mi
+ cpu: 100m
+ limits:
+ memory: 256Mi
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 3200
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /
+ port: 3200
+ initialDelaySeconds: 15
+ periodSeconds: 20
diff --git a/apps/platform-dash/k8s/base/kustomization.yaml b/apps/platform-dash/k8s/base/kustomization.yaml
new file mode 100644
index 0000000..4eb3c7c
--- /dev/null
+++ b/apps/platform-dash/k8s/base/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - deployment.yaml
+ - service.yaml
+ - platform-dash-s3-sealed.yaml
diff --git a/apps/platform-dash/k8s/base/platform-dash-s3-sealed.yaml b/apps/platform-dash/k8s/base/platform-dash-s3-sealed.yaml
new file mode 100644
index 0000000..56ba02f
--- /dev/null
+++ b/apps/platform-dash/k8s/base/platform-dash-s3-sealed.yaml
@@ -0,0 +1,14 @@
+---
+apiVersion: bitnami.com/v1alpha1
+kind: SealedSecret
+metadata:
+ name: platform-dash-s3
+ namespace: apps
+spec:
+ encryptedData:
+ access-key-id: AgA5dczUYVciPrR3vYCXM2zTem1Xy1AB7niJIHiQdAbfiOre/cC3sREnZjC6+cItdi4HIVqP1EtBAMNRKzK7SgtTsyeGx2E/KO3VlU3hOn2tc2Ms/9IvVEPvZ7rJsI2cD4BQ+D7uyhnC1SpAKJEUkGS7bQi0iXXMVFOFJmbZoTBYAgSBsgMxF0TcBfvC+K6535xFSBZm0oBRjqzlpL/+hIB2HZP+bl/RvCBiUiZ50tO1JfkQpaYoLeKifsd7XKvAn8jLbZ1NBCcAC3oBDFXnphO4FCQoQIuXEGEFAjxzCP3fg5qg+VNy3mCNfQ5Nv0fnM/wNAz/vpb7vwf+bHSMBYeph2cmEUqZROOef81gptkLUDIgbu3nRB5LO753wMvbDRLQQyi0DMj8o/s+EF8fntNY83s6HUYNKm6vJ3IN7g+taM44nXqC0FofBFOKkB1+9Po4hRrvsmtp8sBq5X11Bxep2EA0RbkWWEiOlimxaqTsTa8mNVrr8dt4CT3igB9Vi4H5mPG4uS1rhFoIPRPT41S/GXIilqTjo+BQMaODto8WPd+Rfgk7neBPdK4JGaG/g/lyNXuIsXCTIhX+JWu+csAe5FPkxBgsNREGhkuDYO9ggxlZ6X8X8NNrG/F6KW1CFHUmZaqWuNwXXtjXK8k0JAaqO3Plau7oH5oulBbjaUUlTmUU3VTvy1ZHRU1OIHq9z4m8kQnXE/M3ne12ZU479/gfYODl9E0b9txckkA==
+ secret-access-key: AgAjfLw8HTFZC4GDro9GnlbsdkxzK+6EmGe+eQXn5Lclgpqd8EHcjZTsRHSvY+83q9IPM2S/Dv8OLe0bSe+RcbUUa7uZIjNO5m0Qfe/g6ykISuCQm93ixLFd7DzFOcxAVemXL+7nW4s6Xmrkdv9lvPxlCB3AcxbAW3eg5jzRg9zYRjPhvakZaNlaKRdeS34Z04/JmWp39LQmWynVaRUnY0iRV3jRUQH8VjZAXr+YMOrG4uNdNM8P9f00/8hk749ZRtWuIqLiou79kExYALyelmWnNTkyxc0hHxUD1Kvb7aOwQjUH5qyga5TKukitsUzJP/8H0xiTKOmBr9xn3yYvN0mFLdyBNkoSAsbfhBAnWlyTMSLJFqCqmWfxuITqs6k8XL4WRT77cQSC0BAX0u4Gswvo7D7o/Jpu8HU2/RBrGqNC21esIk2VrFiuxdzu9My6Eo8UFMqYZVXZ5zmxm/E/uu4QtrVm1ZLdt6sVc1zK7KJ1dC7pn+7L9YywCC+A7kP8VTM0uZaMcwekzWgGhJyS4IBkVgiJepk8BOYxX/EAfoPLDK4BrMQO5ulY4V8IBWdUAS5gxj7B2CSMYjn2VtbVeJU5Ov4pAElI38RoU3pZIo1rp/fOhGv+GJfFBY/+vH9A61qw6fCTjHIS9uvKbL8i9lu8JXpanA3rH+WgP1oCbNgL/qNtFd4vcsyW8qNCju79JcLym59BTNSQnAteoNiMuM3rkqeaXWJvTVMmAPrNW/lVp953H7+tkuByRJ59MMjsmXoFy4cBKRb1wulyrA7b5cGp
+ template:
+ metadata:
+ name: platform-dash-s3
+ namespace: apps
diff --git a/apps/platform-dash/k8s/base/service.yaml b/apps/platform-dash/k8s/base/service.yaml
new file mode 100644
index 0000000..9dd50e4
--- /dev/null
+++ b/apps/platform-dash/k8s/base/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: platform-dash
+ namespace: apps
+ labels:
+ app: platform-dash
+spec:
+ type: ClusterIP
+ ports:
+ - port: 80
+ targetPort: 3200
+ protocol: TCP
+ selector:
+ app: platform-dash
diff --git a/apps/platform-dash/k8s/overlays/preview/kustomization.yaml b/apps/platform-dash/k8s/overlays/preview/kustomization.yaml
new file mode 100644
index 0000000..2333422
--- /dev/null
+++ b/apps/platform-dash/k8s/overlays/preview/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - ../../base
diff --git a/apps/platform-dash/k8s/overlays/production/kustomization.yaml b/apps/platform-dash/k8s/overlays/production/kustomization.yaml
new file mode 100644
index 0000000..5ffc685
--- /dev/null
+++ b/apps/platform-dash/k8s/overlays/production/kustomization.yaml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - ../../base
+images:
+ - name: gitea.coreworlds.io/lazorgurl/homelab-platform-dash
+ newName: gitea.coreworlds.io/lazorgurl/homelab-platform-dash
+ newTag: latest
diff --git a/apps/platform-dash/next-env.d.ts b/apps/platform-dash/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/apps/platform-dash/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/platform-dash/next.config.js b/apps/platform-dash/next.config.js
new file mode 100644
index 0000000..c835379
--- /dev/null
+++ b/apps/platform-dash/next.config.js
@@ -0,0 +1,5 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: "standalone",
+};
+module.exports = nextConfig;
diff --git a/apps/platform-dash/package.json b/apps/platform-dash/package.json
new file mode 100644
index 0000000..b6e83c0
--- /dev/null
+++ b/apps/platform-dash/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@homelab/platform-dash",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3200",
+ "build": "next build",
+ "start": "next start -p 3200",
+ "lint": "next lint",
+ "test": "echo 'no tests yet'"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.750.0",
+ "@aws-sdk/s3-request-presigner": "^3.750.0",
+ "next": "^15.1.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/apps/platform-dash/src/app/api/s3/download/route.ts b/apps/platform-dash/src/app/api/s3/download/route.ts
new file mode 100644
index 0000000..dec2e2b
--- /dev/null
+++ b/apps/platform-dash/src/app/api/s3/download/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server";
+import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
+
+const BUCKET = process.env.S3_BUCKET || "artifacts";
+
+function getClient() {
+ return new S3Client({
+ endpoint: process.env.S3_ENDPOINT || "http://garage.platform.svc:3900",
+ region: process.env.S3_REGION || "garage",
+ credentials: {
+ accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
+ },
+ forcePathStyle: true,
+ });
+}
+
+export async function GET(request: NextRequest) {
+ const key = request.nextUrl.searchParams.get("key");
+ if (!key) {
+ return NextResponse.json({ error: "key required" }, { status: 400 });
+ }
+
+ const client = getClient();
+
+ try {
+ const result = await client.send(
+ new GetObjectCommand({ Bucket: BUCKET, Key: key })
+ );
+
+ const filename = key.split("/").pop() || key;
+ const stream = result.Body as ReadableStream;
+
+ return new NextResponse(stream, {
+ headers: {
+ "content-type": result.ContentType || "application/octet-stream",
+ "content-disposition": `attachment; filename="${filename}"`,
+ ...(result.ContentLength ? { "content-length": String(result.ContentLength) } : {}),
+ },
+ });
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : "Download failed";
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
diff --git a/apps/platform-dash/src/app/api/s3/route.ts b/apps/platform-dash/src/app/api/s3/route.ts
new file mode 100644
index 0000000..ad5a16b
--- /dev/null
+++ b/apps/platform-dash/src/app/api/s3/route.ts
@@ -0,0 +1,90 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ S3Client,
+ ListObjectsV2Command,
+ PutObjectCommand,
+ DeleteObjectCommand,
+} from "@aws-sdk/client-s3";
+
+const BUCKET = process.env.S3_BUCKET || "artifacts";
+
+function getClient() {
+ return new S3Client({
+ endpoint: process.env.S3_ENDPOINT || "http://garage.platform.svc:3900",
+ region: process.env.S3_REGION || "garage",
+ credentials: {
+ accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
+ },
+ forcePathStyle: true,
+ });
+}
+
+// GET — list objects
+export async function GET(request: NextRequest) {
+ const prefix = request.nextUrl.searchParams.get("prefix") || "";
+ const client = getClient();
+
+ try {
+ const result = await client.send(
+ new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix || undefined })
+ );
+
+ const objects = (result.Contents || []).map((obj) => ({
+ key: obj.Key,
+ size: obj.Size,
+ lastModified: obj.LastModified?.toISOString(),
+ }));
+
+ return NextResponse.json({ objects });
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : "S3 error";
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
+
+// PUT — upload object
+export async function PUT(request: NextRequest) {
+ const key = request.nextUrl.searchParams.get("key");
+ if (!key) {
+ return NextResponse.json({ error: "key required" }, { status: 400 });
+ }
+
+ const body = await request.arrayBuffer();
+ const client = getClient();
+
+ try {
+ await client.send(
+ new PutObjectCommand({
+ Bucket: BUCKET,
+ Key: key,
+ Body: Buffer.from(body),
+ ContentType: request.headers.get("content-type") || "application/octet-stream",
+ })
+ );
+ return NextResponse.json({ ok: true });
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : "Upload failed";
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
+
+// DELETE — remove object
+export async function DELETE(request: NextRequest) {
+ const key = request.nextUrl.searchParams.get("key");
+ if (!key) {
+ return NextResponse.json({ error: "key required" }, { status: 400 });
+ }
+
+ const client = getClient();
+
+ try {
+ await client.send(
+ new DeleteObjectCommand({ Bucket: BUCKET, Key: key })
+ );
+ return NextResponse.json({ ok: true });
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : "Delete failed";
+ return NextResponse.json({ error: msg }, { status: 500 });
+ }
+}
diff --git a/apps/platform-dash/src/app/layout.tsx b/apps/platform-dash/src/app/layout.tsx
new file mode 100644
index 0000000..2658e9b
--- /dev/null
+++ b/apps/platform-dash/src/app/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Platform Dashboard",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/platform-dash/src/app/page.tsx b/apps/platform-dash/src/app/page.tsx
new file mode 100644
index 0000000..295095f
--- /dev/null
+++ b/apps/platform-dash/src/app/page.tsx
@@ -0,0 +1,363 @@
+"use client";
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import {
+ tokens,
+ Label,
+ Mono,
+ Btn,
+ Panel,
+ PanelHeader,
+ Divider,
+} from "@/components/design-system";
+
+interface S3Object {
+ key: string;
+ size: number;
+ lastModified: string;
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${(bytes / Math.pow(k, i)).toFixed(i > 0 ? 1 : 0)} ${sizes[i]}`;
+}
+
+function formatDate(iso: string): string {
+ const d = new Date(iso);
+ return d.toLocaleDateString("en-US", {
+ month: "short", day: "numeric", year: "numeric",
+ hour: "2-digit", minute: "2-digit",
+ });
+}
+
+function pathParts(key: string): { dir: string; name: string } {
+ const i = key.lastIndexOf("/");
+ return i === -1
+ ? { dir: "", name: key }
+ : { dir: key.slice(0, i + 1), name: key.slice(i + 1) };
+}
+
+export default function PlatformDash() {
+ const [objects, setObjects] = useState([]);
+ const [prefix, setPrefix] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState(null);
+ const [dragOver, setDragOver] = useState(false);
+ const fileRef = useRef(null);
+
+ const load = useCallback(async (pfx: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await fetch(`/api/s3?prefix=${encodeURIComponent(pfx)}`);
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ setObjects(data.objects || []);
+ setPrefix(pfx);
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : "Failed to load");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { load(""); }, [load]);
+
+ // Derive "folders" and files from current prefix
+ const folders = new Set();
+ const files: S3Object[] = [];
+ for (const obj of objects) {
+ const rest = obj.key.slice(prefix.length);
+ const slash = rest.indexOf("/");
+ if (slash !== -1) {
+ folders.add(prefix + rest.slice(0, slash + 1));
+ } else if (rest) {
+ files.push(obj);
+ }
+ }
+
+ const breadcrumbs = prefix ? prefix.split("/").filter(Boolean) : [];
+
+ async function handleUpload(fileList: FileList | null) {
+ if (!fileList || fileList.length === 0) return;
+ setUploading(true);
+ setError(null);
+ try {
+ for (const file of Array.from(fileList)) {
+ const key = prefix + file.name;
+ const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}`, {
+ method: "PUT",
+ headers: { "content-type": file.type || "application/octet-stream" },
+ body: file,
+ });
+ if (!res.ok) throw new Error(await res.text());
+ }
+ await load(prefix);
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : "Upload failed");
+ } finally {
+ setUploading(false);
+ }
+ }
+
+ async function handleDelete(key: string) {
+ if (!confirm(`Delete ${key}?`)) return;
+ setError(null);
+ try {
+ const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) throw new Error(await res.text());
+ await load(prefix);
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : "Delete failed");
+ }
+ }
+
+ function handleDrop(e: React.DragEvent) {
+ e.preventDefault();
+ setDragOver(false);
+ handleUpload(e.dataTransfer.files);
+ }
+
+ return (
+
+
+
+ PLATFORM DASHBOARD
+
+
+ garage object store · artifacts bucket
+
+
+
+
+
+
+ load(prefix)} variant="default">REFRESH
+ fileRef.current?.click()}
+ variant="primary"
+ disabled={uploading}
+ >
+ {uploading ? "UPLOADING..." : "UPLOAD"}
+
+ handleUpload(e.target.files)}
+ />
+
+
+
+ {/* Breadcrumbs */}
+
+ load("")}
+ style={{
+ cursor: "pointer",
+ color: prefix ? tokens.color.accent : tokens.color.text0,
+ fontSize: tokens.size.sm,
+ }}
+ >
+ /
+
+ {breadcrumbs.map((seg, i) => {
+ const path = breadcrumbs.slice(0, i + 1).join("/") + "/";
+ const isLast = i === breadcrumbs.length - 1;
+ return (
+
+ /
+ !isLast && load(path)}
+ style={{
+ cursor: isLast ? "default" : "pointer",
+ color: isLast ? tokens.color.text0 : tokens.color.accent,
+ fontSize: tokens.size.sm,
+ fontFamily: tokens.font.mono,
+ }}
+ >
+ {seg}
+
+
+ );
+ })}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Drop zone + listing */}
+ { e.preventDefault(); setDragOver(true); }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={handleDrop}
+ style={{
+ minHeight: 200,
+ border: dragOver ? `2px dashed ${tokens.color.accent}` : "2px solid transparent",
+ transition: `border ${tokens.transition.fast}`,
+ }}
+ >
+ {loading ? (
+
+ Loading...
+
+ ) : (
+ <>
+ {/* Parent dir */}
+ {prefix && (
+
{
+ const parts = prefix.slice(0, -1).split("/");
+ parts.pop();
+ load(parts.length ? parts.join("/") + "/" : "");
+ }}
+ />
+ )}
+
+ {/* Folders */}
+ {Array.from(folders).sort().map((f) => (
+ load(f)}
+ />
+ ))}
+
+ {/* Files */}
+ {files.sort((a, b) => a.key.localeCompare(b.key)).map((obj) => (
+ handleDelete(obj.key)}
+ />
+ ))}
+
+ {folders.size === 0 && files.length === 0 && !prefix && (
+
+
+ Bucket is empty. Drag files here or click UPLOAD.
+
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
+
+function Row({ icon, name, onClick }: { icon: string; name: string; onClick: () => void }) {
+ const [hover, setHover] = useState(false);
+ return (
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ style={{
+ padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
+ cursor: "pointer",
+ display: "flex",
+ alignItems: "center",
+ gap: tokens.space[2],
+ borderBottom: `1px solid ${tokens.color.border0}`,
+ background: hover ? tokens.color.bg2 : "transparent",
+ transition: `background ${tokens.transition.fast}`,
+ }}
+ >
+ {icon}
+ {name}
+
+ );
+}
+
+function FileRow({ obj, prefix, onDelete }: { obj: S3Object; prefix: string; onDelete: () => void }) {
+ const [hover, setHover] = useState(false);
+ const { name } = pathParts(obj.key);
+
+ return (
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ style={{
+ padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
+ display: "flex",
+ alignItems: "center",
+ gap: tokens.space[2],
+ borderBottom: `1px solid ${tokens.color.border0}`,
+ background: hover ? tokens.color.bg2 : "transparent",
+ transition: `background ${tokens.transition.fast}`,
+ }}
+ >
+
·
+
{name}
+
{formatBytes(obj.size)}
+
{formatDate(obj.lastModified)}
+ {hover && (
+
+
+ DL
+
+
{ e.stopPropagation(); onDelete(); }}
+ style={{
+ color: tokens.color.fail,
+ fontSize: tokens.size.xs,
+ fontFamily: tokens.font.mono,
+ cursor: "pointer",
+ }}
+ >
+ DEL
+
+
+ )}
+
+ );
+}
diff --git a/apps/platform-dash/src/components/design-system.tsx b/apps/platform-dash/src/components/design-system.tsx
new file mode 100644
index 0000000..78486aa
--- /dev/null
+++ b/apps/platform-dash/src/components/design-system.tsx
@@ -0,0 +1,541 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+
+// ============================================================
+// HARNESS DESIGN SYSTEM
+// Import this file and destructure what you need.
+// ============================================================
+
+// ─── TOKENS ─────────────────────────────────────────────────
+
+export const tokens = {
+ // Colour palette
+ color: {
+ // Backgrounds — darkest to lightest
+ bg0: "#060a0f", // page root
+ bg1: "#0d1117", // card / panel
+ bg2: "#111827", // nested surface
+ bg3: "#1f2937", // hover state / divider fill
+
+ // Borders
+ border0: "#1f2937", // structural borders (topbar, panel edges)
+ border1: "#374151", // interactive borders (buttons, inputs)
+ border2: "#4b5563", // focus rings
+
+ // Text
+ text0: "#f9fafb", // primary — headings, active labels
+ text1: "#9ca3af", // secondary — body, descriptions
+ text2: "#4b5563", // muted — metadata, timestamps
+ text3: "#374151", // faintest — placeholders, dividers
+
+ // Semantic — signal colours
+ pass: "#00ff9f", // success, running, online
+ passDim: "#064e3b", // pass border / bg tint
+ fail: "#f87171", // failure, error
+ failDim: "#7f1d1d", // fail border / bg tint
+ warn: "#f59e0b", // stale, warning
+ warnDim: "#78350f", // warn border / bg tint
+ info: "#7dd3fc", // completed, informational
+ infoDim: "#0c4a6e", // info border / bg tint
+ purple: "#a78bfa", // decision records, AI-authored
+ purpleDim: "#3b0764", // purple border / bg tint
+ muted: "#6b7280", // pending, disabled, unknown
+
+ // Accent — brand
+ accent: "#00ff9f", // == pass, primary accent
+ accentGlow:"0 0 8px #00ff9f",
+ },
+
+ // Typography
+ font: {
+ mono: "'Courier New', 'Lucida Console', monospace",
+ sans: "'IBM Plex Sans', 'Helvetica Neue', sans-serif",
+ },
+
+ // Font sizes (px)
+ size: {
+ xs: 13,
+ sm: 14,
+ md: 15,
+ base:16,
+ lg: 18,
+ xl: 26,
+ xxl: 34,
+ },
+
+ // Letter spacing
+ tracking: {
+ tight: "0.02em",
+ normal: "0.08em",
+ wide: "0.12em",
+ wider: "0.15em",
+ },
+
+ // Spacing (px) — 4pt grid
+ space: {
+ 1: 4,
+ 2: 8,
+ 3: 12,
+ 4: 16,
+ 5: 20,
+ 6: 24,
+ 8: 32,
+ } as Record,
+
+ // Border radius — intentionally minimal (tool aesthetic)
+ radius: {
+ none: 0,
+ sm: 2,
+ },
+
+ // Transitions
+ transition: {
+ fast: "all 0.1s ease",
+ normal: "all 0.15s ease",
+ },
+
+ // Touch targets
+ touch: { min: 44 },
+};
+
+// ─── STATUS CONFIG ───────────────────────────────────────────
+// Single source of truth for all status variants
+
+export const STATUS: Record = {
+ running: { label: "RUNNING", color: tokens.color.pass, dim: tokens.color.passDim, dot: true },
+ completed: { label: "COMPLETED", color: tokens.color.info, dim: tokens.color.infoDim, dot: false },
+ pending: { label: "PENDING", color: tokens.color.muted, dim: tokens.color.bg3, dot: false },
+ failed: { label: "FAILED", color: tokens.color.fail, dim: tokens.color.failDim, dot: false },
+ passed: { label: "PASSED", color: tokens.color.pass, dim: tokens.color.passDim, dot: false },
+ stale: { label: "STALE", color: tokens.color.warn, dim: tokens.color.warnDim, dot: false },
+ verified: { label: "VERIFIED", color: tokens.color.pass, dim: tokens.color.passDim, dot: false },
+ decision: { label: "DECISION", color: tokens.color.purple, dim: tokens.color.purpleDim, dot: false },
+ open: { label: "OPEN", color: tokens.color.info, dim: tokens.color.infoDim, dot: false },
+};
+
+// ─── PRIMITIVE COMPONENTS ────────────────────────────────────
+
+// Label — all-caps mono metadata tag
+export function Label({ children, color, style }: { children: React.ReactNode; color?: string; style?: React.CSSProperties }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Mono — inline monospace text, body weight
+export function Mono({ children, size, color, style }: { children: React.ReactNode; size?: number; color?: string; style?: React.CSSProperties }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Divider — horizontal rule
+export function Divider({ style }: { style?: React.CSSProperties }) {
+ return (
+
+ );
+}
+
+// StatusBadge — RUNNING / FAILED / VERIFIED etc.
+export function StatusBadge({ status, style }: { status: string; style?: React.CSSProperties }) {
+ const s = STATUS[status] || { label: (status || "UNKNOWN").toUpperCase(), color: tokens.color.muted, dim: tokens.color.bg3, dot: false };
+ return (
+
+ {s.dot && (
+
+ )}
+ {s.label}
+
+ );
+}
+
+// Panel — surface container
+export function Panel({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
+ return (
+
+ {children}
+
+ );
+}
+
+// PanelHeader — labelled top edge of a panel
+export function PanelHeader({ label, children, style }: { label: string; children?: React.ReactNode; style?: React.CSSProperties }) {
+ return (
+
+
+ {children &&
{children}
}
+
+ );
+}
+
+// Btn — button with variants
+export function Btn({ children, variant = "default", onClick, style, disabled }: {
+ children: React.ReactNode;
+ variant?: "primary" | "danger" | "default" | "ghost";
+ onClick?: (e: React.MouseEvent) => void;
+ style?: React.CSSProperties;
+ disabled?: boolean;
+}) {
+ const [hov, setHov] = useState(false);
+ const v = {
+ primary: { border: tokens.color.accent, color: tokens.color.accent },
+ danger: { border: tokens.color.fail, color: tokens.color.fail },
+ default: { border: tokens.color.border1, color: tokens.color.text1 },
+ ghost: { border: "transparent", color: tokens.color.text2 },
+ }[variant];
+ return (
+
+ );
+}
+
+// Input — text input field
+export function Input({ value, onChange, placeholder, style }: {
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+ placeholder?: string;
+ style?: React.CSSProperties;
+}) {
+ const [foc, setFoc] = useState(false);
+ return (
+ setFoc(true)} onBlur={() => setFoc(false)}
+ style={{
+ background: tokens.color.bg0,
+ border: `1px solid ${foc ? tokens.color.border2 : tokens.color.border0}`,
+ color: tokens.color.text0, fontFamily: tokens.font.mono,
+ fontSize: tokens.size.lg,
+ padding: `${tokens.space[3]}px ${tokens.space[3]}px`,
+ minHeight: tokens.touch.min,
+ outline: "none", transition: tokens.transition.fast,
+ borderRadius: 0, width: "100%", boxSizing: "border-box" as const, ...style,
+ }} />
+ );
+}
+
+// Textarea
+export function Textarea({ value, onChange, placeholder, rows = 4, style }: {
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+ placeholder?: string;
+ rows?: number;
+ style?: React.CSSProperties;
+}) {
+ const [foc, setFoc] = useState(false);
+ return (
+