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 ( +