Add platform-dash: S3 artifact browser for Garage object store
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
apps/platform-dash/.dockercontext
Normal file
1
apps/platform-dash/.dockercontext
Normal file
@@ -0,0 +1 @@
|
||||
root
|
||||
30
apps/platform-dash/Dockerfile
Normal file
30
apps/platform-dash/Dockerfile
Normal 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"]
|
||||
59
apps/platform-dash/k8s/base/deployment.yaml
Normal file
59
apps/platform-dash/k8s/base/deployment.yaml
Normal 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
|
||||
6
apps/platform-dash/k8s/base/kustomization.yaml
Normal file
6
apps/platform-dash/k8s/base/kustomization.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- platform-dash-s3-sealed.yaml
|
||||
14
apps/platform-dash/k8s/base/platform-dash-s3-sealed.yaml
Normal file
14
apps/platform-dash/k8s/base/platform-dash-s3-sealed.yaml
Normal 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
|
||||
15
apps/platform-dash/k8s/base/service.yaml
Normal file
15
apps/platform-dash/k8s/base/service.yaml
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- ../../base
|
||||
@@ -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
6
apps/platform-dash/next-env.d.ts
vendored
Normal 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.
|
||||
5
apps/platform-dash/next.config.js
Normal file
5
apps/platform-dash/next.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
module.exports = nextConfig;
|
||||
25
apps/platform-dash/package.json
Normal file
25
apps/platform-dash/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
apps/platform-dash/src/app/api/s3/download/route.ts
Normal file
45
apps/platform-dash/src/app/api/s3/download/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
90
apps/platform-dash/src/app/api/s3/route.ts
Normal file
90
apps/platform-dash/src/app/api/s3/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
apps/platform-dash/src/app/layout.tsx
Normal file
15
apps/platform-dash/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
apps/platform-dash/src/app/page.tsx
Normal file
363
apps/platform-dash/src/app/page.tsx
Normal 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 · 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>
|
||||
);
|
||||
}
|
||||
541
apps/platform-dash/src/components/design-system.tsx
Normal file
541
apps/platform-dash/src/components/design-system.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/platform-dash/tsconfig.json
Normal file
21
apps/platform-dash/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -75,3 +75,16 @@ spec:
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- s3.coreworlds.io
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: platform-dash-tls
|
||||
namespace: platform
|
||||
spec:
|
||||
secretName: platform-dash-tls
|
||||
issuerRef:
|
||||
name: letsencrypt-production
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
- platform.coreworlds.io
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: platform-dash
|
||||
namespace: platform
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-production
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`platform.coreworlds.io`)
|
||||
kind: Rule
|
||||
middlewares:
|
||||
- name: internal-only
|
||||
namespace: platform
|
||||
services:
|
||||
- name: platform-dash
|
||||
namespace: apps
|
||||
port: 80
|
||||
tls:
|
||||
secretName: platform-dash-tls
|
||||
@@ -10,5 +10,6 @@ resources:
|
||||
- ingressroute-harness.yaml
|
||||
- ingressroute-gitea.yaml
|
||||
- ingressroute-garage.yaml
|
||||
- ingressroute-platform-dash.yaml
|
||||
- certificate-internal.yaml
|
||||
- servicemonitor.yaml
|
||||
|
||||
1248
pnpm-lock.yaml
generated
1248
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user