From e0bc28844400e24961930926058070fabf644f14 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 2 Jul 2026 18:01:05 +0800 Subject: [PATCH] feat(panel): implement image build pipeline and admin whitelist with mock dev api --- panel/dev/mockApi.ts | 232 +++++++- panel/src/App.tsx | 2 + panel/src/i18n/resources/en-US/admin.json | 43 +- .../src/i18n/resources/en-US/navigation.json | 3 +- panel/src/i18n/resources/zh-CN/admin.json | 43 +- .../src/i18n/resources/zh-CN/navigation.json | 3 +- panel/src/lib/api.test.ts | 60 ++ panel/src/lib/api.ts | 23 + panel/src/lib/nav.ts | 2 + panel/src/lib/types.ts | 18 + panel/src/pages/admin/ImageAdmin.tsx | 435 +++++++++++++-- panel/src/pages/admin/ImageBuildPage.tsx | 522 ++++++++++++++++++ 12 files changed, 1315 insertions(+), 71 deletions(-) create mode 100644 panel/src/pages/admin/ImageBuildPage.tsx diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index d625bed..e5866d5 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -3,6 +3,7 @@ import type { Plugin } from "vite"; import type { AutostartPolicy, BackupView, + Build, CreateServerRequest, FleetServer, Identity, @@ -16,7 +17,7 @@ const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const; type AccountID = (typeof ACCOUNT_IDS)[number]; type Role = "admin" | "user"; -type Method = "GET" | "POST"; +type Method = "GET" | "POST" | "DELETE"; type CreateError = | "bad_request" | "already_exists" @@ -47,14 +48,9 @@ interface MockState { accounts: Record; servers: MockServer[]; images: WhitelistImage[]; - // Per-server §access state, keyed by server name. Lazily created (accessFor) so a - // server only gets an entry once its access is touched; "survival" is pre-seeded - // so the whitelist panel demos a populated list out of the box. access: Record; - // World backups (GET /backups). Global, not keyed by server — the page filters by - // server_name client-side, mirroring the real global list endpoint. Scoped per - // caller at dispatch (admin sees all; a user only worlds they formerly owned). backups: BackupView[]; + builds: Build[]; } // PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock @@ -242,6 +238,25 @@ function initialState(): MockState { }, }, backups: mockBackups(), + builds: [ + { + id: "bld-1", + image_ref: "registry.felis.svc:5000/modpack-beta:1.0", + status: "succeeded", + requested_by: "owner@mock.felis.local", + created_at: new Date(Date.now() - 3600000).toISOString(), + finished_at: new Date(Date.now() - 3500000).toISOString(), + }, + { + id: "bld-2", + image_ref: "registry.felis.svc:5000/forge-broken:1.0", + status: "failed", + error: "trivy found a CRITICAL CVE: CVE-2026-12345 in library/forge", + requested_by: "owner@mock.felis.local", + created_at: new Date(Date.now() - 1800000).toISOString(), + finished_at: new Date(Date.now() - 1700000).toISOString(), + }, + ], }; } @@ -535,9 +550,6 @@ async function handleSession(ctx: SessionContext): Promise { ctx.account.mustChangePassword = false; sendJSON(ctx.res, 200, { ok: true }); return true; - case "GET images": - sendJSON(ctx.res, 200, { images: ctx.state.images }); - return true; case "GET backups": // Admin sees every archive; a user only worlds they formerly owned — mirrors // AllBackups vs BackupsForUser. The panel filters by server_name client-side. @@ -557,10 +569,210 @@ async function handleSession(ctx: SessionContext): Promise { await verifyLinkRoute(ctx); return true; default: + if (await handleImageRoute(ctx)) return true; return await handleServerRoute(ctx); } } +async function handleImageRoute(ctx: SessionContext): Promise { + if (ctx.parts[2] !== "images") return false; + + // GET /api/v1/images + if (is("GET", ctx) && ctx.parts.length === 3) { + sendJSON(ctx.res, 200, { images: ctx.state.images }); + return true; + } + + // POST /api/v1/images (add image) + if (is("POST", ctx) && ctx.parts.length === 3) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const body = await readJSON<{ image_ref?: string }>(ctx.req); + const ref = body.image_ref?.trim(); + if (!ref) { + sendError(ctx.res, 400, "bad_request", "image_ref is required"); + return true; + } + // Check if already exists in whitelist + let img = ctx.state.images.find((i) => i.image_ref === ref); + if (img) { + img.enabled = true; + } else { + img = { image_ref: ref, enabled: true, source: "external" }; + ctx.state.images.unshift(img); + } + sendJSON(ctx.res, 201, img); + return true; + } + + // DELETE /api/v1/images (remove image) + if (is("DELETE", ctx) && ctx.parts.length === 3) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const url = new URL(ctx.req.url ?? "/", "http://localhost"); + const ref = url.searchParams.get("ref"); + if (!ref) { + sendError(ctx.res, 400, "bad_request", "ref query parameter is required"); + return true; + } + const idx = ctx.state.images.findIndex((i) => i.image_ref === ref); + if (idx < 0) { + sendError(ctx.res, 404, "not_found", "image not found"); + return true; + } + ctx.state.images.splice(idx, 1); + ctx.res.statusCode = 204; + ctx.res.end(); + return true; + } + + // POST /api/v1/images/build (trigger build) + if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const body = await readJSON<{ image_ref?: string; dockerfile?: string; context_ref?: string; base_image?: string }>(ctx.req); + if (!body.image_ref || !body.dockerfile || !body.context_ref) { + sendError(ctx.res, 400, "bad_request", "image_ref, dockerfile, and context_ref are required"); + return true; + } + const newBuild: Build = { + id: `bld-${Date.now()}`, + image_ref: body.image_ref.trim(), + status: "building", + dockerfile: body.dockerfile, + context_ref: body.context_ref.trim(), + base_image: body.base_image?.trim(), + requested_by: ctx.account.email, + created_at: new Date().toISOString(), + }; + ctx.state.builds.unshift(newBuild); + + // Mock build progression in a timeout + setTimeout(() => { + const b = ctx.state.builds.find((x) => x.id === newBuild.id); + if (b && b.status === "building") { + b.status = "succeeded"; + b.finished_at = new Date().toISOString(); + // Add to whitelist images + if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) { + ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" }); + } + } + }, 15000); // Succeeded after 15 seconds + + sendJSON(ctx.res, 202, newBuild); + return true; + } + + // GET /api/v1/images/build (list builds) + if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + sendJSON(ctx.res, 200, { builds: ctx.state.builds }); + return true; + } + + // GET /api/v1/images/build/{id} (get build) + if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[4] && ctx.parts.length === 5) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const build = ctx.state.builds.find((b) => b.id === ctx.parts[4]); + if (!build) { + sendError(ctx.res, 404, "not_found", "build not found"); + return true; + } + sendJSON(ctx.res, 200, build); + return true; + } + + // POST /api/v1/images/build/{id}/cancel (cancel build) + if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "cancel" && ctx.parts.length === 6) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const buildID = ctx.parts[4]; + const build = ctx.state.builds.find((b) => b.id === buildID); + if (!build) { + sendError(ctx.res, 404, "not_found", "build not found"); + return true; + } + if (build.status === "succeeded" || build.status === "failed" || build.status === "cancelled") { + sendError(ctx.res, 409, "already_terminal", "build already terminal"); + return true; + } + build.status = "cancelled"; + build.finished_at = new Date().toISOString(); + sendJSON(ctx.res, 200, build); + return true; + } + + // GET /api/v1/images/build/{id}/logs (SSE logs stream) + if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "logs" && ctx.parts.length === 6) { + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + const buildID = ctx.parts[4]; + const build = ctx.state.builds.find((b) => b.id === buildID); + if (!build) { + sendError(ctx.res, 404, "not_found", "build not found"); + return true; + } + streamBuildLogs(ctx.req, ctx.res, buildID); + return true; + } + + return false; +} + +function streamBuildLogs( + req: IncomingMessage, + res: ServerResponse, + buildID: string +): void { + const lines = [ + `[INFO] [Kaniko] Starting build for ID: ${buildID}`, + "[INFO] [Kaniko] Pulling base image library/postgres:15", + "[INFO] [Kaniko] Successfully pulled base image", + "[INFO] [Kaniko] Executing: RUN echo 'setup'", + "[INFO] [Kaniko] Pushing image to registry.felis.svc:5000", + "[INFO] [Trivy] Starting security scan...", + "[INFO] [Trivy] Scanning registry.felis.svc:5000/image", + "[INFO] [Trivy] No critical vulnerabilities found. Scan PASSED.", + `[INFO] [System] Build succeeded for ${buildID}`, + ]; + let i = 0; + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write(": connected\n\n"); + + const timer = setInterval(() => { + if (i < lines.length) { + res.write(`data: ${lines[i]}\n\n`); + i++; + } else { + clearInterval(timer); + } + }, 1000); + + req.on("close", () => clearInterval(timer)); +} + async function createServerRoute(ctx: SessionContext): Promise { if (ctx.account.role !== "admin") { sendError(ctx.res, 403, "forbidden", "admin account required"); diff --git a/panel/src/App.tsx b/panel/src/App.tsx index b25c3bc..3e6d63e 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -13,6 +13,7 @@ import { ServerPlayers } from "@/pages/ServerPlayers"; import { ServerBackups } from "@/pages/ServerBackups"; import { Account } from "@/pages/Account"; import { ImageAdmin } from "@/pages/admin/ImageAdmin"; +import { ImageBuildPage } from "@/pages/admin/ImageBuildPage"; // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): // / User-Side — app-tier, every authenticated principal @@ -51,6 +52,7 @@ export default function App() { }> } /> } /> + } /> } /> diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 4f4491d..5e97921 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -8,12 +8,49 @@ "images_desc": "The platform image whitelist — the value space the create form draws from.", "server_admin_subtitle": "Create platform servers from the structured form and manage the ones you operate.", "images_title": "Images", - "images_subtitle": "The platform image whitelist. Only enabled images can back a new server. Editing the whitelist from the Web is auditable — the mutation routes are a pending admin-tier slice, so this view is read-only for now.", + "images_subtitle": "The platform image whitelist. Only enabled images can back a new server. You can add external images or delete unwanted images from the whitelist.", + "add_image_title": "Add External Image", + "add_image_btn": "Add Image", + "image_ref_placeholder": "e.g. registry.felis.svc:5000/image:tag", + "delete_image_tooltip": "Delete Whitelist Image", + "delete_confirm": "Are you sure you want to delete this image?", + "cancel_confirm": "Are you sure you want to cancel this build job?", + "delete_btn": "Delete", + "builds_title": "Build Pipeline", + "builds_subtitle": "History of image build jobs and trigger controls. The build pod runs inside an isolated namespace with automated Trivy scans.", + "trigger_build_title": "Trigger New Image Build", + "trigger_build_btn": "Start Build", + "dockerfile_label": "Dockerfile Content", + "dockerfile_placeholder": "FROM library/postgres:15\nRUN echo 'setup'", + "context_ref_label": "Context Reference", + "context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz", + "base_image_label": "Base Image", + "base_image_placeholder": "e.g. library/postgres:15", + "build_history_title": "Build History", + "view_logs_btn": "Logs", + "cancel_build_btn": "Cancel", + "no_builds_title": "No Builds Found", + "no_builds_hint": "You can trigger your first image build task using the form on the top right.", + "build_log_title": "Build Log Terminal", + "log_streaming": "Streaming...", + "log_finished": "Finished", "no_images_title": "No images whitelisted", "no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).", "enabled": "enabled", "disabled": "disabled", "footer_kubectl": "kubectl / CRD operations", "footer_pre": "Cluster scaling, RBAC, Secrets and control-plane lifecycle are ", - "footer_post": " and are intentionally not available from the panel — the four-power separation (build / runtime / operator / app) is preserved. This is a window onto the platform, not a lever for operator power." -} + "footer_post": " and are intentionally not available from the panel — the four-power separation (build / runtime / operator / app) is preserved. This is a window onto the platform, not a lever for operator power.", + "table_ref": "Image Reference", + "table_source": "Source", + "table_status": "Status", + "table_action": "Action", + "table_build_id": "Build ID", + "table_requester": "Requester", + "table_created_at": "Created At", + "table_duration": "Duration", + "filter_all": "All", + "filter_enabled": "Enabled", + "filter_disabled": "Disabled", + "image_ref_label": "Image Reference" +} \ No newline at end of file diff --git a/panel/src/i18n/resources/en-US/navigation.json b/panel/src/i18n/resources/en-US/navigation.json index 3d738b9..0c9a6b3 100644 --- a/panel/src/i18n/resources/en-US/navigation.json +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -3,5 +3,6 @@ "my_servers": "Servers", "account": "Account", "admin_section": "Admin", - "admin_images": "Images" + "admin_images": "Images", + "admin_builds": "Build Pipeline" } diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 48e6e8c..4512667 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -8,12 +8,49 @@ "images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。", "server_admin_subtitle": "通过结构化表单创建并管理平台服务器。", "images_title": "镜像", - "images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。Web 端编辑白名单可审计——变更接口待后续上线,当前仅可查看。", + "images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。您可以添加外部镜像,或将不需要的镜像从白名单中删除。", + "add_image_title": "添加外部镜像", + "add_image_btn": "添加镜像", + "image_ref_placeholder": "例如: registry.felis.svc:5000/image:tag", + "delete_image_tooltip": "删除白名单镜像", + "delete_confirm": "确定删除此镜像吗?", + "cancel_confirm": "确定要取消该构建任务吗?", + "delete_btn": "删除", + "builds_title": "构建流水线", + "builds_subtitle": "平台镜像构建任务历史及触发终端。构建 Pod 运行在隔离沙箱中,完成构建后将自动运行安全扫描。", + "trigger_build_title": "触发新镜像构建", + "trigger_build_btn": "开始构建", + "dockerfile_label": "Dockerfile 内容", + "dockerfile_placeholder": "FROM library/postgres:15\nRUN echo 'setup'", + "context_ref_label": "构建上下文引用", + "context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz", + "base_image_label": "基础镜像", + "base_image_placeholder": "例如: library/postgres:15", + "build_history_title": "构建历史", + "view_logs_btn": "日志", + "cancel_build_btn": "取消", + "no_builds_title": "暂无构建任务", + "no_builds_hint": "您可以使用右上角表单触发第一个镜像构建任务。", + "build_log_title": "构建日志终端", + "log_streaming": "实时输出中", + "log_finished": "已结束", "no_images_title": "无白名单镜像", "no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。", "enabled": "已启用", "disabled": "已禁用", "footer_kubectl": "kubectl / CRD", "footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ", - "footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。" -} + "footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。", + "table_ref": "镜像名称", + "table_source": "来源", + "table_status": "状态", + "table_action": "操作", + "table_build_id": "构建 ID", + "table_requester": "发起人", + "table_created_at": "创建时间", + "table_duration": "耗时", + "filter_all": "全部", + "filter_enabled": "已启用", + "filter_disabled": "已禁用", + "image_ref_label": "镜像引用" +} \ No newline at end of file diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json index 7d17b03..1ba03f4 100644 --- a/panel/src/i18n/resources/zh-CN/navigation.json +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -3,5 +3,6 @@ "my_servers": "服务器", "account": "账户", "admin_section": "管理", - "admin_images": "镜像" + "admin_images": "镜像", + "admin_builds": "构建流水线" } diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 1e46b58..b13ec0e 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -324,3 +324,63 @@ describe("api access-control wire shapes", () => { expect(humanizeError({ code: "console_unavailable" })).toMatch(/console/i); }); }); + +describe("image whitelist and builds wire shapes", () => { + beforeEach(() => vi.restoreAllMocks()); + afterEach(() => vi.unstubAllGlobals()); + + it("addImage POSTs {image_ref} to /images", async () => { + const fetchSpy = fakeFetch({ image_ref: "x", source: "external", enabled: true }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.addImage("x"); + expect(res.image_ref).toBe("x"); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/images"); + expect((opts as RequestInit).method).toBe("POST"); + expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ image_ref: "x" }); + }); + + it("removeImage DELETEs with ref in query params to /images", async () => { + const fetchSpy = fakeFetch(null); + vi.stubGlobal("fetch", fetchSpy); + await api.removeImage("x"); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/images?ref=x"); + expect((opts as RequestInit).method).toBe("DELETE"); + }); + + it("buildImage POSTs build details to /images/build", async () => { + const fetchSpy = fakeFetch({ id: "bld-1", image_ref: "x", status: "pending" }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.buildImage({ image_ref: "x", dockerfile: "FROM x", context_ref: "c" }); + expect(res.id).toBe("bld-1"); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/images/build"); + expect((opts as RequestInit).method).toBe("POST"); + expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ + image_ref: "x", + dockerfile: "FROM x", + context_ref: "c", + }); + }); + + it("getBuild GETs build status from /images/build/{id}", async () => { + const fetchSpy = fakeFetch({ id: "bld-1", image_ref: "x", status: "building" }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.getBuild("bld-1"); + expect(res.status).toBe("building"); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/images/build/bld-1"); + expect((opts as RequestInit).method).toBe("GET"); + }); + + it("cancelBuild POSTs to cancel endpoint /images/build/{id}/cancel", async () => { + const fetchSpy = fakeFetch({ id: "bld-1", image_ref: "x", status: "cancelled" }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.cancelBuild("bld-1"); + expect(res.status).toBe("cancelled"); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/images/build/bld-1/cancel"); + expect((opts as RequestInit).method).toBe("POST"); + }); +}); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 120ef05..d1fb08f 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -4,6 +4,7 @@ import type { AutostartPolicy, BackupView, BanlistResult, + Build, CreateServerRequest, FleetServer, Identity, @@ -156,6 +157,21 @@ export const api = { listImages: () => request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []), + addImage: (imageRef: string) => + request("POST", "/images", { image_ref: imageRef }), + + removeImage: (imageRef: string) => + request("DELETE", `/images?ref=${encodeURIComponent(imageRef)}`), + + buildImage: (req: { image_ref: string; dockerfile: string; context_ref: string; base_image?: string }) => + request("POST", "/images/build", req), + + getBuild: (id: string) => + request("GET", `/images/build/${id}`), + + cancelBuild: (id: string) => + request("POST", `/images/build/${id}/cancel`), + createServer: (req: CreateServerRequest) => request<{ name: string; subdomain: string; desiredState: string }>( "POST", @@ -229,6 +245,13 @@ export function consoleStreamURL(apiBase: string, name: string): string { return `${apiBase}/servers/${encodeURIComponent(name)}/console`; } +/** + * buildLogsStreamURL builds the SSE endpoint for build logs. + */ +export function buildLogsStreamURL(apiBase: string, id: string): string { + return `${apiBase}/images/build/${encodeURIComponent(id)}/logs`; +} + /** humanizeError turns the stable error code into a user-facing line. */ export function humanizeError(e: unknown): string { const err = e as Partial; diff --git a/panel/src/lib/nav.ts b/panel/src/lib/nav.ts index 1ae3f48..eba1b02 100644 --- a/panel/src/lib/nav.ts +++ b/panel/src/lib/nav.ts @@ -3,6 +3,7 @@ import { Server, UserRound, Boxes, + Cpu, type LucideIcon, } from "lucide-react"; @@ -51,6 +52,7 @@ export const NAV_SECTIONS: NavSection[] = [ adminOnly: true, items: [ { to: "/admin/images", key: "admin_images", icon: Boxes }, + { to: "/admin/builds", key: "admin_builds", icon: Cpu }, ], }, ]; diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index dc89754..fdf08da 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -226,3 +226,21 @@ export interface LoginResult { role: "user" | "admin"; must_change_password: boolean; } + +export type BuildStatus = "pending" | "building" | "succeeded" | "failed" | "cancelled"; + +/** Build mirrors an image_builds row (spec §6, §16). */ +export interface Build { + id: string; + image_ref: string; + status: BuildStatus; + dockerfile?: string; + context_ref?: string; + base_image?: string; + requested_by: string; + job_name?: string; + log_ref?: string; + error?: string; + created_at: string; + finished_at?: string; +} diff --git a/panel/src/pages/admin/ImageAdmin.tsx b/panel/src/pages/admin/ImageAdmin.tsx index ccf3167..626e75d 100644 --- a/panel/src/pages/admin/ImageAdmin.tsx +++ b/panel/src/pages/admin/ImageAdmin.tsx @@ -1,70 +1,399 @@ -import { Boxes, CheckCircle2, CircleSlash } from "lucide-react"; +import { useState, useMemo } from "react"; +import { Boxes, CheckCircle2, CircleSlash, Plus, Trash2, Loader2, Search, CheckCircle, type LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; import { Loading, ErrorState, EmptyState } from "@/components/States"; -import { api } from "@/lib/api"; +import { Pagination } from "@/components/Pagination"; +import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; -// ImageAdmin is the Admin-Side view of the platform image whitelist (spec §14 — -// "镜像白名单(Postgres, Web 动态改可审计)"). The READ is real (GET /images); the -// add / enable / disable WRITE routes are an admin-tier mutation slice still to be -// added, so this page surfaces the whitelist honestly and labels the missing -// mutation rather than shipping dead buttons. +const PAGE_SIZE = 10; + +function StatCard({ + icon: Icon, + label, + value, + accentClass, +}: { + icon: LucideIcon; + label: string; + value: number; + accentClass?: string; +}) { + return ( + + +
+ +
+
+
{value}
+
{label}
+
+
+
+ ); +} + export function ImageAdmin() { const { t } = useTranslation("admin"); const { data, error, loading, reload } = useAsync(() => api.listImages(), []); const images = data ?? []; + // Form & Dialog State + const [dialogOpen, setDialogOpen] = useState(false); + const [newImageRef, setNewImageRef] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [deletingRef, setDeletingRef] = useState(null); + + // Search & Filtering State + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all"); + const [page, setPage] = useState(1); + + // Stats + const stats = useMemo(() => { + const total = images.length; + const enabled = images.filter((img) => img.enabled).length; + const disabled = total - enabled; + return { total, enabled, disabled }; + }, [images]); + + // Filtered & Paginated Images + const filteredImages = useMemo(() => { + let list = [...images]; + + // 1. Search Filter + if (search.trim()) { + const q = search.toLowerCase(); + list = list.filter( + (img) => + img.image_ref.toLowerCase().includes(q) || + (img.source && img.source.toLowerCase().includes(q)) + ); + } + + // 2. Status Filter + if (statusFilter === "enabled") { + list = list.filter((img) => img.enabled); + } else if (statusFilter === "disabled") { + list = list.filter((img) => !img.enabled); + } + + return list; + }, [images, search, statusFilter]); + + // Reset page when filter changes + const lastFilterKey = `${search}-${statusFilter}`; + const [prevFilterKey, setPrevFilterKey] = useState(lastFilterKey); + if (prevFilterKey !== lastFilterKey) { + setPage(1); + setPrevFilterKey(lastFilterKey); + } + + const paginatedImages = useMemo(() => { + const start = (page - 1) * PAGE_SIZE; + return filteredImages.slice(start, start + PAGE_SIZE); + }, [filteredImages, page]); + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + if (!newImageRef.trim()) return; + setSubmitting(true); + setSubmitError(null); + try { + await api.addImage(newImageRef.trim()); + setNewImageRef(""); + setDialogOpen(false); + reload(); + } catch (err) { + setSubmitError(humanizeError(err)); + } finally { + setSubmitting(false); + } + } + + async function handleRemove(imageRef: string) { + if (!confirm(t("delete_confirm"))) return; + setDeletingRef(imageRef); + try { + await api.removeImage(imageRef); + reload(); + } catch (err) { + alert(humanizeError(err)); + } finally { + setDeletingRef(null); + } + } + return ( - <> -
- -
-

{t("images_title")}

-

- {t("images_subtitle")} -

+
+ {/* Header */} +
+
+ +
+

{t("images_title")}

+

+ {t("images_subtitle")} +

+
+ + {/* Dialog for Add External Image */} + { + setDialogOpen(o); + if (!o) { + setNewImageRef(""); + setSubmitError(null); + } + }}> + + + + + + {t("add_image_title")} + + 将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。 + + +
+
+ + setNewImageRef(e.target.value)} + disabled={submitting} + required + /> +
+ {submitError && ( +

+ {submitError} +

+ )} + + + + +
+
+
- {loading && !data ? ( - - ) : error ? ( - - ) : images.length === 0 ? ( - - ) : ( -
- {images.map((img) => ( - - -
- - {img.image_ref} - {img.source && ( - - {img.source} - - )} -
- {img.enabled ? ( - - {t("enabled")} - - ) : ( - - {t("disabled")} - + {/* Stats Cards Row */} +
+ + + +
+ + {/* Whitelist Table Card */} + + + {/* Filters Bar */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+
- )} - + > + {t("filter_all")} ({stats.total}) + + + +
+
+ + {/* List Content */} + {loading && !data ? ( +
+ ) : error ? ( +
+ ) : filteredImages.length === 0 ? ( +
+ +
+ ) : ( +
+ {/* Table Header */} +
+
{t("table_ref")}
+
{t("table_source")}
+
{t("table_status")}
+
{t("table_action")}
+
+ + {/* Table Body */} +
+ {paginatedImages.map((img) => ( +
+ {/* Reference */} +
+ + + {img.image_ref} + +
+ + {/* Source */} +
+ {img.source ? ( + + {img.source} + + ) : ( + — + )} +
+ + {/* Status */} +
+ {img.enabled ? ( + + {t("enabled")} + + ) : ( + + {t("disabled")} + + )} +
+ + {/* Action */} +
+ +
+
+ ))} +
+ + {/* Pagination */} + {filteredImages.length > PAGE_SIZE && ( +
+ +
+ )} +
+ )} + + +
); -} +} \ No newline at end of file diff --git a/panel/src/pages/admin/ImageBuildPage.tsx b/panel/src/pages/admin/ImageBuildPage.tsx new file mode 100644 index 0000000..159a634 --- /dev/null +++ b/panel/src/pages/admin/ImageBuildPage.tsx @@ -0,0 +1,522 @@ +import { useEffect, useState, useMemo, Fragment } from "react"; +import { Cpu, Play, Terminal, Loader2, XCircle, AlertCircle, Plus, Search, ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { LogConsole } from "@/components/LogConsole"; +import { cn } from "@/lib/utils"; +import { Loading, EmptyState } from "@/components/States"; +import { Pagination } from "@/components/Pagination"; +import { api, buildLogsStreamURL, humanizeError } from "@/lib/api"; +import { formatRelative, formatAbsolute } from "@/lib/format"; +import { useConfig } from "@/lib/hooks"; +import type { Build, BuildStatus } from "@/lib/types"; + +const LOCAL_STORAGE_KEY = "felis_triggered_builds"; +const PAGE_SIZE = 10; + +const STATUS_BADGE_STYLE: Record = { + pending: "bg-amber-500/10 text-amber-500 border-amber-500/20 animate-pulse", + building: "bg-blue-500/10 text-blue-500 border-blue-500/20 animate-pulse", + succeeded: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + failed: "bg-rose-500/10 text-rose-500 border-rose-500/20", + cancelled: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", +}; + +function formatDuration(createdAt: string, finishedAt?: string, isZh?: boolean): string { + const start = new Date(createdAt).getTime(); + if (!Number.isFinite(start)) return ""; + const end = finishedAt ? new Date(finishedAt).getTime() : Date.now(); + if (!Number.isFinite(end) || end < start) return ""; + const diffSec = Math.round((end - start) / 1000); + if (diffSec < 60) { + return isZh ? `${diffSec}秒` : `${diffSec}s`; + } + const m = Math.floor(diffSec / 60); + const s = diffSec % 60; + return isZh ? `${m}分${s}秒` : `${m}m ${s}s`; +} + +export function ImageBuildPage() { + const { t, i18n } = useTranslation("admin"); + const locale = i18n.language; + const now = Date.now(); + const isZh = locale.startsWith("zh"); + const config = useConfig(); + + // Form & Dialog State + const [dialogOpen, setDialogOpen] = useState(false); + const [imageRef, setImageRef] = useState(""); + const [contextRef, setContextRef] = useState(""); + const [dockerfile, setDockerfile] = useState(""); + const [baseImage, setBaseImage] = useState(""); + const [triggering, setTriggering] = useState(false); + const [triggerError, setTriggerError] = useState(null); + + // Build List State + const [buildIds, setBuildIds] = useState([]); + const [builds, setBuilds] = useState([]); + const [loadingBuilds, setLoadingBuilds] = useState(true); + const [activeLogBuildId, setActiveLogBuildId] = useState(null); + const [cancellingId, setCancellingId] = useState(null); + + // Search & Pagination State + const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); + + // Load build IDs from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(LOCAL_STORAGE_KEY); + if (stored) { + setBuildIds(JSON.parse(stored)); + } else { + // Seed default IDs so mock builds are loaded on first visit. + // In production, querying these will 404 and safely show the empty state. + const defaultIds = ["bld-1", "bld-2"]; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(defaultIds)); + setBuildIds(defaultIds); + } + } catch { + setLoadingBuilds(false); + } + }, []); + + // Fetch full details of builds + const fetchBuilds = async (ids: string[]) => { + if (ids.length === 0) { + setBuilds([]); + setLoadingBuilds(false); + return; + } + try { + const list = await Promise.all( + ids.map(async (id) => { + try { + return await api.getBuild(id); + } catch { + return null; // ignore individual failures + } + }) + ); + // Filter out nulls and sort by creation time (newest first) + const validBuilds = list.filter((b): b is Build => b !== null); + validBuilds.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + setBuilds(validBuilds); + } catch { + // ignore + } finally { + setLoadingBuilds(false); + } + }; + + // Fetch builds when IDs change + useEffect(() => { + fetchBuilds(buildIds); + }, [buildIds]); + + // Poll active builds + useEffect(() => { + const active = builds.some((b) => b.status === "pending" || b.status === "building"); + if (!active) return; + + const timer = setInterval(() => { + fetchBuilds(buildIds); + }, 4000); + + return () => clearInterval(timer); + }, [builds, buildIds]); + + // Filtered & Paginated Builds + const filteredBuilds = useMemo(() => { + let list = [...builds]; + if (search.trim()) { + const q = search.toLowerCase(); + list = list.filter( + (b) => + b.id.toLowerCase().includes(q) || + b.image_ref.toLowerCase().includes(q) || + b.status.toLowerCase().includes(q) + ); + } + return list; + }, [builds, search]); + + // Reset page when search changes + useMemo(() => { + setPage(1); + }, [search]); + + const paginatedBuilds = useMemo(() => { + const start = (page - 1) * PAGE_SIZE; + return filteredBuilds.slice(start, start + PAGE_SIZE); + }, [filteredBuilds, page]); + + // Form submission handler + const handleTrigger = async (e: React.FormEvent) => { + e.preventDefault(); + if (!imageRef.trim() || !contextRef.trim() || !dockerfile.trim()) return; + + setTriggering(true); + setTriggerError(null); + try { + const newBuild = await api.buildImage({ + image_ref: imageRef.trim(), + context_ref: contextRef.trim(), + dockerfile: dockerfile, + base_image: baseImage.trim() || undefined, + }); + + // Save build ID to localStorage and update state + const nextIds = [newBuild.id, ...buildIds]; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(nextIds)); + setBuildIds(nextIds); + setActiveLogBuildId(newBuild.id); + setDialogOpen(false); + + // Clear form + setImageRef(""); + setContextRef(""); + setDockerfile(""); + setBaseImage(""); + } catch (err) { + setTriggerError(humanizeError(err)); + } finally { + setTriggering(false); + } + }; + + // Cancel build handler + const handleCancel = async (id: string) => { + if (!confirm(t("cancel_confirm"))) return; + setCancellingId(id); + try { + await api.cancelBuild(id); + fetchBuilds(buildIds); + } catch (err) { + alert(humanizeError(err)); + } finally { + setCancellingId(null); + } + }; + + if (!config) { + return ; + } + + return ( +
+ {/* Header */} +
+
+ +
+

{t("builds_title")}

+

{t("builds_subtitle")}

+
+
+ + {/* Dialog for Trigger Build */} + { + setDialogOpen(o); + if (!o) { + setTriggerError(null); + } + }}> + + + + + + {t("trigger_build_title")} + + 输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。 + + +
+
+
+ + setImageRef(e.target.value)} + disabled={triggering} + required + /> +
+ +
+ + setContextRef(e.target.value)} + disabled={triggering} + required + /> +
+
+ +
+ + setBaseImage(e.target.value)} + disabled={triggering} + /> +
+ +
+ +