Add platform-dash: S3 artifact browser for Garage object store
Some checks failed
CI / lint-and-test (push) Failing after 30s
CI / build (push) Has been skipped
Deploy Production / deploy (push) Failing after 1m31s

Lightweight Next.js app for browsing, uploading, and downloading
artifacts from the cluster-local Garage S3 bucket. Uses the harness
design system. Features:
- File/folder browser with breadcrumb navigation
- Drag-and-drop upload
- Download and delete
- Ingress at platform.coreworlds.io (internal-only)

Also adds platform-dash to CI/deploy workflows.
This commit is contained in:
Julia McGhee
2026-03-22 10:34:13 +00:00
parent a37a1a6e65
commit 970154769c
23 changed files with 2536 additions and 4 deletions

View File

@@ -0,0 +1 @@
root

View File

@@ -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"]

View File

@@ -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

View File

@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- platform-dash-s3-sealed.yaml

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,4 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base

View File

@@ -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

6
apps/platform-dash/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
module.exports = nextConfig;

View File

@@ -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"
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 (
<html lang="en">
<body style={{ margin: 0, padding: 0, background: "#0a0a0a", color: "#e5e5e5" }}>
{children}
</body>
</html>
);
}

View File

@@ -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<S3Object[]>([]);
const [prefix, setPrefix] = useState("");
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(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<string>();
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 (
<div style={{
maxWidth: 900,
margin: "0 auto",
padding: `${tokens.space[6]}px ${tokens.space[4]}px`,
fontFamily: tokens.font.mono,
}}>
<div style={{ marginBottom: tokens.space[6] }}>
<h1 style={{
fontSize: tokens.size.xl,
fontFamily: tokens.font.mono,
fontWeight: 400,
color: tokens.color.text0,
margin: 0,
letterSpacing: tokens.tracking.tight,
}}>
PLATFORM DASHBOARD
</h1>
<Mono size={tokens.size.sm} color={tokens.color.text3}>
garage object store &middot; artifacts bucket
</Mono>
</div>
<Panel>
<PanelHeader label="ARTIFACTS">
<div style={{ display: "flex", gap: tokens.space[2] }}>
<Btn onClick={() => load(prefix)} variant="default">REFRESH</Btn>
<Btn
onClick={() => fileRef.current?.click()}
variant="primary"
disabled={uploading}
>
{uploading ? "UPLOADING..." : "UPLOAD"}
</Btn>
<input
ref={fileRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => handleUpload(e.target.files)}
/>
</div>
</PanelHeader>
{/* Breadcrumbs */}
<div style={{
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
display: "flex",
alignItems: "center",
gap: tokens.space[1],
borderBottom: `1px solid ${tokens.color.border0}`,
background: tokens.color.bg0,
}}>
<span
onClick={() => load("")}
style={{
cursor: "pointer",
color: prefix ? tokens.color.accent : tokens.color.text0,
fontSize: tokens.size.sm,
}}
>
/
</span>
{breadcrumbs.map((seg, i) => {
const path = breadcrumbs.slice(0, i + 1).join("/") + "/";
const isLast = i === breadcrumbs.length - 1;
return (
<span key={path} style={{ display: "flex", alignItems: "center", gap: tokens.space[1] }}>
<Mono size={tokens.size.xs} color={tokens.color.text3}>/</Mono>
<span
onClick={() => !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}
</span>
</span>
);
})}
</div>
{error && (
<div style={{
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
background: tokens.color.failDim,
color: tokens.color.fail,
fontSize: tokens.size.sm,
fontFamily: tokens.font.mono,
}}>
{error}
</div>
)}
{/* Drop zone + listing */}
<div
onDragOver={(e) => { 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 ? (
<div style={{ padding: tokens.space[4], textAlign: "center" }}>
<Mono size={tokens.size.sm} color={tokens.color.text3}>Loading...</Mono>
</div>
) : (
<>
{/* Parent dir */}
{prefix && (
<Row
icon="↑"
name=".."
onClick={() => {
const parts = prefix.slice(0, -1).split("/");
parts.pop();
load(parts.length ? parts.join("/") + "/" : "");
}}
/>
)}
{/* Folders */}
{Array.from(folders).sort().map((f) => (
<Row
key={f}
icon="▸"
name={f.slice(prefix.length, -1) + "/"}
onClick={() => load(f)}
/>
))}
{/* Files */}
{files.sort((a, b) => a.key.localeCompare(b.key)).map((obj) => (
<FileRow
key={obj.key}
obj={obj}
prefix={prefix}
onDelete={() => handleDelete(obj.key)}
/>
))}
{folders.size === 0 && files.length === 0 && !prefix && (
<div style={{ padding: tokens.space[4], textAlign: "center" }}>
<Mono size={tokens.size.sm} color={tokens.color.text3}>
Bucket is empty. Drag files here or click UPLOAD.
</Mono>
</div>
)}
</>
)}
</div>
</Panel>
</div>
);
}
function Row({ icon, name, onClick }: { icon: string; name: string; onClick: () => void }) {
const [hover, setHover] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => 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}`,
}}
>
<Mono size={tokens.size.sm} color={tokens.color.accent}>{icon}</Mono>
<Mono size={tokens.size.sm} color={tokens.color.text0}>{name}</Mono>
</div>
);
}
function FileRow({ obj, prefix, onDelete }: { obj: S3Object; prefix: string; onDelete: () => void }) {
const [hover, setHover] = useState(false);
const { name } = pathParts(obj.key);
return (
<div
onMouseEnter={() => 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}`,
}}
>
<Mono size={tokens.size.sm} color={tokens.color.text3}>·</Mono>
<Mono size={tokens.size.sm} color={tokens.color.text0} style={{ flex: 1 }}>{name}</Mono>
<Mono size={tokens.size.xs} color={tokens.color.text3}>{formatBytes(obj.size)}</Mono>
<Mono size={tokens.size.xs} color={tokens.color.text3}>{formatDate(obj.lastModified)}</Mono>
{hover && (
<div style={{ display: "flex", gap: tokens.space[1] }}>
<a
href={`/api/s3/download?key=${encodeURIComponent(obj.key)}`}
style={{
color: tokens.color.accent,
fontSize: tokens.size.xs,
fontFamily: tokens.font.mono,
textDecoration: "none",
}}
>
DL
</a>
<span
onClick={(e) => { e.stopPropagation(); onDelete(); }}
style={{
color: tokens.color.fail,
fontSize: tokens.size.xs,
fontFamily: tokens.font.mono,
cursor: "pointer",
}}
>
DEL
</span>
</div>
)}
</div>
);
}

View File

@@ -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<number, number>,
// 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<string, { label: string; color: string; dim: string; dot: boolean }> = {
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 (
<span style={{
fontFamily: tokens.font.mono,
fontSize: tokens.size.xs,
letterSpacing: tokens.tracking.wide,
color: color || tokens.color.text2,
textTransform: "uppercase",
...style,
}}>
{children}
</span>
);
}
// Mono — inline monospace text, body weight
export function Mono({ children, size, color, style }: { children: React.ReactNode; size?: number; color?: string; style?: React.CSSProperties }) {
return (
<span style={{
fontFamily: tokens.font.mono,
fontSize: size || tokens.size.base,
color: color || tokens.color.text1,
...style,
}}>
{children}
</span>
);
}
// Divider — horizontal rule
export function Divider({ style }: { style?: React.CSSProperties }) {
return (
<div style={{
height: 1,
background: tokens.color.border0,
width: "100%",
flexShrink: 0,
...style,
}} />
);
}
// 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 (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "3px 8px",
border: `1px solid ${s.dim}`,
background: s.dim + "44",
fontFamily: tokens.font.mono,
fontSize: tokens.size.xs,
letterSpacing: tokens.tracking.wide,
color: s.color,
whiteSpace: "nowrap",
...style,
}}>
{s.dot && (
<span style={{
width: 5, height: 5,
borderRadius: "50%",
background: s.color,
boxShadow: `0 0 6px ${s.color}`,
display: "inline-block",
animation: "hpulse 2s infinite",
}} />
)}
{s.label}
</span>
);
}
// Panel — surface container
export function Panel({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: tokens.color.bg1,
border: `1px solid ${tokens.color.border0}`,
...style,
}}>
{children}
</div>
);
}
// PanelHeader — labelled top edge of a panel
export function PanelHeader({ label, children, style }: { label: string; children?: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: `0 ${tokens.space[4]}px`,
borderBottom: `1px solid ${tokens.color.border0}`,
height: 44,
flexShrink: 0,
...style,
}}>
<Label color={tokens.color.text2}>{label}</Label>
{children && <div style={{ display: "flex", gap: tokens.space[2] }}>{children}</div>}
</div>
);
}
// 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 (
<button onClick={onClick} disabled={disabled}
onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)}
style={{
background: "transparent", border: `1px solid ${v.border}`,
color: hov && !disabled ? tokens.color.text0 : v.color,
fontFamily: tokens.font.mono, fontSize: tokens.size.sm,
letterSpacing: tokens.tracking.wide,
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
minHeight: tokens.touch.min,
cursor: disabled ? "not-allowed" : "pointer",
transition: tokens.transition.fast, borderRadius: 0,
opacity: disabled ? 0.4 : 1,
display: "inline-flex", alignItems: "center", justifyContent: "center",
...style,
}}>
{children}
</button>
);
}
// Input — text input field
export function Input({ value, onChange, placeholder, style }: {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
style?: React.CSSProperties;
}) {
const [foc, setFoc] = useState(false);
return (
<input value={value} onChange={onChange} placeholder={placeholder}
onFocus={() => 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<HTMLTextAreaElement>) => void;
placeholder?: string;
rows?: number;
style?: React.CSSProperties;
}) {
const [foc, setFoc] = useState(false);
return (
<textarea value={value} onChange={onChange} placeholder={placeholder} rows={rows}
onFocus={() => 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`,
outline: "none", transition: tokens.transition.fast,
resize: "vertical" as const, width: "100%", boxSizing: "border-box" as const,
borderRadius: 0, ...style,
}} />
);
}
// EvalPip — metric readout tile
export function EvalPip({ label, value, unit, pass, target }: {
label: string; value: number | string; unit: string; pass: boolean; target: string;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[1], padding: `${tokens.space[3]}px ${tokens.space[3]}px`, background: tokens.color.bg0, border: `1px solid ${pass ? tokens.color.passDim : tokens.color.failDim}`, minWidth: 96, flexShrink: 0 }}>
<Label color={tokens.color.text2}>{label}</Label>
<span style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.xl, color: pass ? tokens.color.pass : tokens.color.fail, fontWeight: "bold", lineHeight: 1 }}>
{value}<span style={{ fontSize: tokens.size.sm, marginLeft: 2 }}>{unit}</span>
</span>
<Label color={tokens.color.text3}>target {target}</Label>
</div>
);
}
// IterDot — single iteration status pip
export function IterDot({ status, n }: { status?: string; n: number }) {
const color = status === "passed" || status === "completed" ? tokens.color.pass
: status === "failed" ? tokens.color.fail
: status === "running" ? tokens.color.warn
: tokens.color.bg3;
return (
<div title={`Iter ${n}: ${status || "pending"}`}
style={{ width: 18, height: 5, background: color, opacity: status ? 1 : 0.25, boxShadow: status === "running" ? `0 0 6px ${color}` : "none", transition: tokens.transition.normal, flexShrink: 0 }} />
);
}
// PathCrumb — file path display
export function PathCrumb({ path, style }: { path: string; style?: React.CSSProperties }) {
const parts = path.split("/");
return (
<span style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.sm, ...style }}>
{parts.map((p, i) => (
<span key={i}>
<span style={{ color: i === parts.length - 1 ? tokens.color.text1 : tokens.color.text3 }}>{p}</span>
{i < parts.length - 1 && <span style={{ color: tokens.color.text3, margin: "0 2px" }}>/</span>}
</span>
))}
</span>
);
}
// SearchableDropdown — filterable option picker
export interface DropdownOption {
value: string;
label: string;
detail?: string;
}
export function SearchableDropdown({ options, value, onChange, placeholder, style, multi = false }: {
options: DropdownOption[];
value: string | string[];
onChange: (value: string | string[]) => void;
placeholder?: string;
style?: React.CSSProperties;
multi?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = options.filter(o =>
o.label.toLowerCase().includes(query.toLowerCase()) ||
(o.detail && o.detail.toLowerCase().includes(query.toLowerCase()))
);
const selected = multi
? (value as string[])
: value ? [value as string] : [];
const selectedLabels = selected
.map(v => options.find(o => o.value === v)?.label)
.filter(Boolean)
.join(", ");
const toggle = (optValue: string) => {
if (multi) {
const arr = value as string[];
const next = arr.includes(optValue) ? arr.filter(v => v !== optValue) : [...arr, optValue];
onChange(next);
} else {
onChange(optValue);
setOpen(false);
setQuery("");
}
};
const remove = (optValue: string) => {
if (multi) {
onChange((value as string[]).filter(v => v !== optValue));
} else {
onChange("");
}
};
return (
<div ref={ref} style={{ position: "relative", width: "100%", ...style }}>
{/* Selected tags (multi) or trigger */}
<div
onClick={() => setOpen(!open)}
style={{
background: tokens.color.bg0,
border: `1px solid ${open ? tokens.color.border2 : tokens.color.border0}`,
minHeight: tokens.touch.min,
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
display: "flex", alignItems: "center", flexWrap: "wrap", gap: tokens.space[1],
cursor: "pointer", transition: tokens.transition.fast,
}}
>
{multi && selected.length > 0 ? (
selected.map(v => {
const opt = options.find(o => o.value === v);
return (
<span key={v} style={{
display: "inline-flex", alignItems: "center", gap: 4,
padding: "2px 8px",
background: tokens.color.bg2,
border: `1px solid ${tokens.color.border0}`,
fontFamily: tokens.font.mono, fontSize: tokens.size.sm,
color: tokens.color.text1,
}}>
{opt?.label || v}
<span
onClick={(e) => { e.stopPropagation(); remove(v); }}
style={{ cursor: "pointer", color: tokens.color.text2, fontSize: tokens.size.xs, marginLeft: 2 }}
>
x
</span>
</span>
);
})
) : !multi && selectedLabels ? (
<span style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.base, color: tokens.color.text0 }}>
{selectedLabels}
</span>
) : (
<span style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.base, color: tokens.color.text3 }}>
{placeholder || "Select..."}
</span>
)}
<span style={{ marginLeft: "auto", color: tokens.color.text3, fontSize: tokens.size.sm, flexShrink: 0 }}>
{open ? "▴" : "▾"}
</span>
</div>
{/* Dropdown */}
{open && (
<div style={{
position: "absolute", top: "100%", left: 0, right: 0, zIndex: 50,
background: tokens.color.bg1,
border: `1px solid ${tokens.color.border1}`,
borderTop: "none",
maxHeight: 240, display: "flex", flexDirection: "column",
}}>
<div style={{ padding: tokens.space[2], borderBottom: `1px solid ${tokens.color.border0}` }}>
<input
autoFocus
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
style={{
background: tokens.color.bg0,
border: `1px solid ${tokens.color.border0}`,
color: tokens.color.text0,
fontFamily: tokens.font.mono,
fontSize: tokens.size.sm,
padding: `${tokens.space[1]}px ${tokens.space[2]}px`,
outline: "none", width: "100%", boxSizing: "border-box" as const,
borderRadius: 0,
}}
/>
</div>
<div style={{ flex: 1, overflowY: "auto" }}>
{filtered.length === 0 ? (
<div style={{ padding: `${tokens.space[3]}px ${tokens.space[3]}px` }}>
<Mono size={tokens.size.sm} color={tokens.color.text3}>No matches</Mono>
</div>
) : (
filtered.map(opt => {
const isSelected = selected.includes(opt.value);
return (
<div
key={opt.value}
onClick={() => toggle(opt.value)}
style={{
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
cursor: "pointer",
background: isSelected ? tokens.color.bg2 : "transparent",
borderLeft: `2px solid ${isSelected ? tokens.color.accent : "transparent"}`,
display: "flex", flexDirection: "column", gap: 2,
minHeight: 36,
justifyContent: "center",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Mono size={tokens.size.sm} color={isSelected ? tokens.color.text0 : tokens.color.text1}>
{opt.label}
</Mono>
{isSelected && <span style={{ color: tokens.color.accent, fontSize: tokens.size.xs }}></span>}
</div>
{opt.detail && (
<Mono size={tokens.size.xs} color={tokens.color.text3}>{opt.detail}</Mono>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}
// BackBtn — mobile drill-down back button
export function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
return (
<button onClick={onBack} style={{ background: "none", border: "none", color: tokens.color.accent, fontFamily: tokens.font.mono, fontSize: tokens.size.sm, letterSpacing: tokens.tracking.wide, cursor: "pointer", display: "flex", alignItems: "center", gap: tokens.space[2], padding: 0, minHeight: tokens.touch.min }}>
<span style={{ fontSize: 16, lineHeight: 1 }}></span> {label}
</button>
);
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}