feat(panel): implement image build pipeline and admin whitelist with mock dev api
This commit is contained in:
12 files changed
+1295
-51
No files matched your search
+222
-10
@@ -3,6 +3,7 @@ import type { Plugin } from "vite";
|
|||||||
import type {
|
import type {
|
||||||
AutostartPolicy,
|
AutostartPolicy,
|
||||||
BackupView,
|
BackupView,
|
||||||
|
Build,
|
||||||
CreateServerRequest,
|
CreateServerRequest,
|
||||||
FleetServer,
|
FleetServer,
|
||||||
Identity,
|
Identity,
|
||||||
@@ -16,7 +17,7 @@ const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;
|
|||||||
|
|
||||||
type AccountID = (typeof ACCOUNT_IDS)[number];
|
type AccountID = (typeof ACCOUNT_IDS)[number];
|
||||||
type Role = "admin" | "user";
|
type Role = "admin" | "user";
|
||||||
type Method = "GET" | "POST";
|
type Method = "GET" | "POST" | "DELETE";
|
||||||
type CreateError =
|
type CreateError =
|
||||||
| "bad_request"
|
| "bad_request"
|
||||||
| "already_exists"
|
| "already_exists"
|
||||||
@@ -47,14 +48,9 @@ interface MockState {
|
|||||||
accounts: Record<AccountID, MockAccount>;
|
accounts: Record<AccountID, MockAccount>;
|
||||||
servers: MockServer[];
|
servers: MockServer[];
|
||||||
images: WhitelistImage[];
|
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<string, AccessState>;
|
access: Record<string, AccessState>;
|
||||||
// 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[];
|
backups: BackupView[];
|
||||||
|
builds: Build[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
|
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
|
||||||
@@ -242,6 +238,25 @@ function initialState(): MockState {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
backups: mockBackups(),
|
backups: mockBackups(),
|
||||||
|
builds: [
|
||||||
|
{
|
||||||
|
id: "bld-1",
|
||||||
|
image_ref: "registry.felis.svc:5000/modpack-beta:1.0",
|
||||||
|
status: "succeeded",
|
||||||
|
requested_by: "[email protected]",
|
||||||
|
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: "[email protected]",
|
||||||
|
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<boolean> {
|
|||||||
ctx.account.mustChangePassword = false;
|
ctx.account.mustChangePassword = false;
|
||||||
sendJSON(ctx.res, 200, { ok: true });
|
sendJSON(ctx.res, 200, { ok: true });
|
||||||
return true;
|
return true;
|
||||||
case "GET images":
|
|
||||||
sendJSON(ctx.res, 200, { images: ctx.state.images });
|
|
||||||
return true;
|
|
||||||
case "GET backups":
|
case "GET backups":
|
||||||
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
||||||
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
|
// AllBackups vs BackupsForUser. The panel filters by server_name client-side.
|
||||||
@@ -557,10 +569,210 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
|||||||
await verifyLinkRoute(ctx);
|
await verifyLinkRoute(ctx);
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
|
if (await handleImageRoute(ctx)) return true;
|
||||||
return await handleServerRoute(ctx);
|
return await handleServerRoute(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
|
||||||
|
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<void> {
|
async function createServerRoute(ctx: SessionContext): Promise<void> {
|
||||||
if (ctx.account.role !== "admin") {
|
if (ctx.account.role !== "admin") {
|
||||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ServerPlayers } from "@/pages/ServerPlayers";
|
|||||||
import { ServerBackups } from "@/pages/ServerBackups";
|
import { ServerBackups } from "@/pages/ServerBackups";
|
||||||
import { Account } from "@/pages/Account";
|
import { Account } from "@/pages/Account";
|
||||||
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||||
|
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
|
||||||
|
|
||||||
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||||
// / User-Side — app-tier, every authenticated principal
|
// / User-Side — app-tier, every authenticated principal
|
||||||
@@ -51,6 +52,7 @@ export default function App() {
|
|||||||
<Route path="admin" element={<RequireAdmin />}>
|
<Route path="admin" element={<RequireAdmin />}>
|
||||||
<Route index element={<Navigate to="/admin/images" replace />} />
|
<Route index element={<Navigate to="/admin/images" replace />} />
|
||||||
<Route path="images" element={<ImageAdmin />} />
|
<Route path="images" element={<ImageAdmin />} />
|
||||||
|
<Route path="builds" element={<ImageBuildPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -8,12 +8,49 @@
|
|||||||
"images_desc": "The platform image whitelist — the value space the create form draws from.",
|
"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.",
|
"server_admin_subtitle": "Create platform servers from the structured form and manage the ones you operate.",
|
||||||
"images_title": "Images",
|
"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_title": "No images whitelisted",
|
||||||
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
|
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
|
||||||
"enabled": "enabled",
|
"enabled": "enabled",
|
||||||
"disabled": "disabled",
|
"disabled": "disabled",
|
||||||
"footer_kubectl": "kubectl / CRD operations",
|
"footer_kubectl": "kubectl / CRD operations",
|
||||||
"footer_pre": "Cluster scaling, RBAC, Secrets and control-plane lifecycle are ",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
"my_servers": "Servers",
|
"my_servers": "Servers",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"admin_section": "Admin",
|
"admin_section": "Admin",
|
||||||
"admin_images": "Images"
|
"admin_images": "Images",
|
||||||
|
"admin_builds": "Build Pipeline"
|
||||||
}
|
}
|
||||||
@@ -8,12 +8,49 @@
|
|||||||
"images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
|
"images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
|
||||||
"server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
|
"server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
|
||||||
"images_title": "镜像",
|
"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_title": "无白名单镜像",
|
||||||
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
|
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
|
||||||
"enabled": "已启用",
|
"enabled": "已启用",
|
||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"footer_kubectl": "kubectl / CRD",
|
"footer_kubectl": "kubectl / CRD",
|
||||||
"footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ",
|
"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": "镜像引用"
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,6 @@
|
|||||||
"my_servers": "服务器",
|
"my_servers": "服务器",
|
||||||
"account": "账户",
|
"account": "账户",
|
||||||
"admin_section": "管理",
|
"admin_section": "管理",
|
||||||
"admin_images": "镜像"
|
"admin_images": "镜像",
|
||||||
|
"admin_builds": "构建流水线"
|
||||||
}
|
}
|
||||||
@@ -324,3 +324,63 @@ describe("api access-control wire shapes", () => {
|
|||||||
expect(humanizeError({ code: "console_unavailable" })).toMatch(/console/i);
|
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(String(url)).toBe("/images/build/bld-1/cancel");
|
||||||
|
expect((opts as RequestInit).method).toBe("POST");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AutostartPolicy,
|
AutostartPolicy,
|
||||||
BackupView,
|
BackupView,
|
||||||
BanlistResult,
|
BanlistResult,
|
||||||
|
Build,
|
||||||
CreateServerRequest,
|
CreateServerRequest,
|
||||||
FleetServer,
|
FleetServer,
|
||||||
Identity,
|
Identity,
|
||||||
@@ -156,6 +157,21 @@ export const api = {
|
|||||||
listImages: () =>
|
listImages: () =>
|
||||||
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
|
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
|
||||||
|
|
||||||
|
addImage: (imageRef: string) =>
|
||||||
|
request<WhitelistImage>("POST", "/images", { image_ref: imageRef }),
|
||||||
|
|
||||||
|
removeImage: (imageRef: string) =>
|
||||||
|
request<void>("DELETE", `/images?ref=${encodeURIComponent(imageRef)}`),
|
||||||
|
|
||||||
|
buildImage: (req: { image_ref: string; dockerfile: string; context_ref: string; base_image?: string }) =>
|
||||||
|
request<Build>("POST", "/images/build", req),
|
||||||
|
|
||||||
|
getBuild: (id: string) =>
|
||||||
|
request<Build>("GET", `/images/build/${id}`),
|
||||||
|
|
||||||
|
cancelBuild: (id: string) =>
|
||||||
|
request<Build>("POST", `/images/build/${id}/cancel`),
|
||||||
|
|
||||||
createServer: (req: CreateServerRequest) =>
|
createServer: (req: CreateServerRequest) =>
|
||||||
request<{ name: string; subdomain: string; desiredState: string }>(
|
request<{ name: string; subdomain: string; desiredState: string }>(
|
||||||
"POST",
|
"POST",
|
||||||
@@ -229,6 +245,13 @@ export function consoleStreamURL(apiBase: string, name: string): string {
|
|||||||
return `${apiBase}/servers/${encodeURIComponent(name)}/console`;
|
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. */
|
/** humanizeError turns the stable error code into a user-facing line. */
|
||||||
export function humanizeError(e: unknown): string {
|
export function humanizeError(e: unknown): string {
|
||||||
const err = e as Partial<ApiError>;
|
const err = e as Partial<ApiError>;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
UserRound,
|
UserRound,
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Cpu,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||||
|
{ to: "/admin/builds", key: "admin_builds", icon: Cpu },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -226,3 +226,21 @@ export interface LoginResult {
|
|||||||
role: "user" | "admin";
|
role: "user" | "admin";
|
||||||
must_change_password: boolean;
|
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;
|
||||||
|
}
|
||||||
@@ -1,23 +1,150 @@
|
|||||||
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 { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { 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";
|
import { useAsync } from "@/lib/hooks";
|
||||||
|
|
||||||
// ImageAdmin is the Admin-Side view of the platform image whitelist (spec §14 —
|
const PAGE_SIZE = 10;
|
||||||
// "镜像白名单(Postgres, Web 动态改可审计)"). The READ is real (GET /images); the
|
|
||||||
// add / enable / disable WRITE routes are an admin-tier mutation slice still to be
|
function StatCard({
|
||||||
// added, so this page surfaces the whitelist honestly and labels the missing
|
icon: Icon,
|
||||||
// mutation rather than shipping dead buttons.
|
label,
|
||||||
|
value,
|
||||||
|
accentClass,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accentClass?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<div className={`rounded-md p-2 bg-muted/30 ${accentClass || "text-muted-foreground"}`}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold font-mono leading-none">{value}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground font-medium">{label}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ImageAdmin() {
|
export function ImageAdmin() {
|
||||||
const { t } = useTranslation("admin");
|
const { t } = useTranslation("admin");
|
||||||
const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
|
const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
|
||||||
const images = data ?? [];
|
const images = data ?? [];
|
||||||
|
|
||||||
|
// Form & Dialog State
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [newImageRef, setNewImageRef] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [deletingRef, setDeletingRef] = useState<string | null>(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 (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Boxes className="h-6 w-6 text-primary" />
|
<Boxes className="h-6 w-6 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
@@ -28,43 +155,245 @@ export function ImageAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && !data ? (
|
{/* Dialog for Add External Image */}
|
||||||
<Loading />
|
<Dialog open={dialogOpen} onOpenChange={(o) => {
|
||||||
) : error ? (
|
setDialogOpen(o);
|
||||||
<ErrorState error={error} onRetry={reload} />
|
if (!o) {
|
||||||
) : images.length === 0 ? (
|
setNewImageRef("");
|
||||||
<EmptyState
|
setSubmitError(null);
|
||||||
title={t("no_images_title")}
|
}
|
||||||
hint={t("no_images_hint")}
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-1.5 text-xs font-semibold shrink-0">
|
||||||
|
<Plus className="h-4 w-4" /> {t("add_image_btn")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("add_image_title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleAdd} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="imageRefInput" className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("image_ref_label")} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="imageRefInput"
|
||||||
|
placeholder={t("image_ref_placeholder")}
|
||||||
|
value={newImageRef}
|
||||||
|
onChange={(e) => setNewImageRef(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
{submitError && (
|
||||||
|
<p className="text-xs text-destructive mt-1 font-medium bg-destructive/10 p-2.5 rounded-md">
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant={newImageRef.trim() ? "default" : "outline"}
|
||||||
|
disabled={submitting || !newImageRef.trim()}
|
||||||
|
className="text-xs gap-1.5"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<Plus className="h-4 w-4" />
|
||||||
{images.map((img) => (
|
)}
|
||||||
<Card key={img.image_ref}>
|
{t("add_image_btn")}
|
||||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
</Button>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
</DialogFooter>
|
||||||
<Boxes className="h-4 w-4 shrink-0 text-muted-foreground" />
|
</form>
|
||||||
<span className="truncate font-mono text-sm">{img.image_ref}</span>
|
</DialogContent>
|
||||||
{img.source && (
|
</Dialog>
|
||||||
<Badge variant="outline" className="shrink-0">
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards Row */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<StatCard icon={Boxes} label="总镜像数" value={stats.total} />
|
||||||
|
<StatCard icon={CheckCircle} label="已启用" value={stats.enabled} accentClass="text-primary bg-primary/10" />
|
||||||
|
<StatCard icon={CircleSlash} label="已禁用" value={stats.disabled} accentClass="text-muted-foreground bg-muted/20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whitelist Table Card */}
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* Filters Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b">
|
||||||
|
<div className="relative flex-1 w-full">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索镜像名称或来源..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground shrink-0 select-none border border-border/40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter("all");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
statusFilter === "all"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/30 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("filter_all")} ({stats.total})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter("enabled");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
statusFilter === "enabled"
|
||||||
|
? "bg-background text-primary shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/30 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("filter_enabled")} ({stats.enabled})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter("disabled");
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
statusFilter === "disabled"
|
||||||
|
? "bg-background text-foreground/80 shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/30 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("filter_disabled")} ({stats.disabled})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Content */}
|
||||||
|
{loading && !data ? (
|
||||||
|
<div className="py-12"><Loading /></div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="py-12"><ErrorState error={error} onRetry={reload} /></div>
|
||||||
|
) : filteredImages.length === 0 ? (
|
||||||
|
<div className="p-4 border-b-0">
|
||||||
|
<EmptyState
|
||||||
|
title={search.trim() || statusFilter !== "all" ? "无匹配结果" : t("no_images_title")}
|
||||||
|
hint={search.trim() || statusFilter !== "all" ? "尝试更换搜索词或筛选条件" : t("no_images_hint")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Table Header */}
|
||||||
|
<div className="hidden md:grid grid-cols-12 gap-3 px-4 py-2.5 text-[11px] font-semibold text-muted-foreground border-b select-none">
|
||||||
|
<div className="col-span-7">{t("table_ref")}</div>
|
||||||
|
<div className="col-span-2">{t("table_source")}</div>
|
||||||
|
<div className="col-span-2 text-center">{t("table_status")}</div>
|
||||||
|
<div className="col-span-1 text-right">{t("table_action")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Body */}
|
||||||
|
<div className="divide-y divide-border select-text">
|
||||||
|
{paginatedImages.map((img) => (
|
||||||
|
<div
|
||||||
|
key={img.image_ref}
|
||||||
|
className={`grid grid-cols-1 md:grid-cols-12 gap-3 items-center px-4 py-2 text-xs transition-colors ${
|
||||||
|
img.enabled
|
||||||
|
? "hover:bg-accent/40 text-foreground"
|
||||||
|
: "bg-muted/10 text-muted-foreground/80 opacity-60 hover:bg-muted/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Reference */}
|
||||||
|
<div className="col-span-7 flex items-center gap-2.5 min-w-0 py-1">
|
||||||
|
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||||
|
<span className="font-mono font-medium truncate select-all" title={img.image_ref}>
|
||||||
|
{img.image_ref}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<div className="col-span-2 py-1">
|
||||||
|
{img.source ? (
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-medium capitalize">
|
||||||
{img.source}
|
{img.source}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="col-span-2 md:text-center py-1">
|
||||||
|
{img.enabled ? (
|
||||||
|
<Badge variant="default" className="text-[10px] px-1.5 py-0 font-semibold">
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-1 shrink-0" /> {t("enabled")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="muted" className="text-[10px] px-1.5 py-0 font-semibold">
|
||||||
|
<CircleSlash className="h-3 w-3 mr-1 shrink-0" /> {t("disabled")}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{img.enabled ? (
|
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-primary">
|
{/* Action */}
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" /> {t("enabled")}
|
<div className="col-span-1 text-right py-1">
|
||||||
</span>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemove(img.image_ref)}
|
||||||
|
disabled={deletingRef === img.image_ref}
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
|
title={t("delete_image_tooltip")}
|
||||||
|
>
|
||||||
|
{deletingRef === img.image_ref ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<CircleSlash className="h-3.5 w-3.5" /> {t("disabled")}
|
)}
|
||||||
</span>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{filteredImages.length > PAGE_SIZE && (
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
total={filteredImages.length}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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<BuildStatus, string> = {
|
||||||
|
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<string | null>(null);
|
||||||
|
|
||||||
|
// Build List State
|
||||||
|
const [buildIds, setBuildIds] = useState<string[]>([]);
|
||||||
|
const [builds, setBuilds] = useState<Build[]>([]);
|
||||||
|
const [loadingBuilds, setLoadingBuilds] = useState(true);
|
||||||
|
const [activeLogBuildId, setActiveLogBuildId] = useState<string | null>(null);
|
||||||
|
const [cancellingId, setCancellingId] = useState<string | null>(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 <Loading label={t("common:loading_config")} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Cpu className="h-6 w-6 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{t("builds_title")}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("builds_subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialog for Trigger Build */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={(o) => {
|
||||||
|
setDialogOpen(o);
|
||||||
|
if (!o) {
|
||||||
|
setTriggerError(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-1.5 text-xs font-semibold shrink-0">
|
||||||
|
<Plus className="h-4 w-4" /> {t("trigger_build_title")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("trigger_build_title")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleTrigger} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="imageRef" className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("image_ref_label")} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="imageRef"
|
||||||
|
placeholder="registry.felis.svc:5000/my-modpack:1.0"
|
||||||
|
value={imageRef}
|
||||||
|
onChange={(e) => setImageRef(e.target.value)}
|
||||||
|
disabled={triggering}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="contextRef" className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("context_ref_label")} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="contextRef"
|
||||||
|
placeholder={t("context_ref_placeholder")}
|
||||||
|
value={contextRef}
|
||||||
|
onChange={(e) => setContextRef(e.target.value)}
|
||||||
|
disabled={triggering}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="baseImage" className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("base_image_label")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="baseImage"
|
||||||
|
placeholder={t("base_image_placeholder")}
|
||||||
|
value={baseImage}
|
||||||
|
onChange={(e) => setBaseImage(e.target.value)}
|
||||||
|
disabled={triggering}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="dockerfile" className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("dockerfile_label")} *
|
||||||
|
</Label>
|
||||||
|
<textarea
|
||||||
|
id="dockerfile"
|
||||||
|
placeholder={t("dockerfile_placeholder")}
|
||||||
|
value={dockerfile}
|
||||||
|
onChange={(e) => setDockerfile(e.target.value)}
|
||||||
|
disabled={triggering}
|
||||||
|
required
|
||||||
|
rows={8}
|
||||||
|
className="w-full rounded-md border border-input bg-zinc-950 px-3 py-2 text-xs font-mono text-zinc-200 shadow-sm placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{triggerError && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-destructive bg-destructive/10 p-2.5 rounded-md font-medium">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{triggerError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={triggering}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t("common:cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant={imageRef.trim() && contextRef.trim() && dockerfile.trim() ? "default" : "outline"}
|
||||||
|
disabled={triggering || !imageRef.trim() || !contextRef.trim() || !dockerfile.trim()}
|
||||||
|
className="text-xs gap-1.5"
|
||||||
|
>
|
||||||
|
{triggering ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("trigger_build_btn")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build History Table Card */}
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* Filters Bar */}
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索构建 ID、镜像引用或状态..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Content */}
|
||||||
|
{loadingBuilds && builds.length === 0 ? (
|
||||||
|
<div className="py-12"><Loading /></div>
|
||||||
|
) : filteredBuilds.length === 0 ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<EmptyState
|
||||||
|
title={search.trim() ? "无匹配构建任务" : t("no_builds_title")}
|
||||||
|
hint={search.trim() ? "尝试更换搜索词" : t("no_builds_hint")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground select-none">
|
||||||
|
<th className="px-4 py-2.5 font-medium text-left">{t("table_build_id")}</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium text-left">{t("table_ref")}</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium text-center">{t("table_status")}</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium text-left">{t("table_requester")}</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium text-center">{t("table_created_at")}</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium text-center">{t("table_duration")}</th>
|
||||||
|
<th className="px-4 py-2.5 font-medium text-right">{t("table_action")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{paginatedBuilds.map((b) => (
|
||||||
|
<Fragment key={b.id}>
|
||||||
|
<tr className={cn(
|
||||||
|
"hover:bg-accent/40 transition-colors",
|
||||||
|
activeLogBuildId === b.id && "bg-primary/5 hover:bg-primary/5"
|
||||||
|
)}>
|
||||||
|
{/* Column 1: Build ID */}
|
||||||
|
<td className="px-4 py-3 align-middle font-mono font-bold text-muted-foreground select-all text-left">
|
||||||
|
{b.id}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Column 2: Image Reference & Error */}
|
||||||
|
<td className="px-4 py-3 align-middle text-left">
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span
|
||||||
|
className="font-mono font-medium text-foreground select-all block max-w-xl truncate"
|
||||||
|
title={`镜像引用: ${b.image_ref}${b.base_image ? `\n基础镜像: ${b.base_image}` : ""}${b.context_ref ? `\n构建上下文: ${b.context_ref}` : ""}`}
|
||||||
|
>
|
||||||
|
{b.image_ref}
|
||||||
|
</span>
|
||||||
|
{b.error && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-destructive font-medium mt-1 select-text" title={b.error}>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive/80" />
|
||||||
|
<span className="break-all whitespace-pre-wrap">{b.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Column 3: Status */}
|
||||||
|
<td className="px-4 py-3 align-middle text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] py-0.5 font-semibold uppercase tracking-wider w-[80px] justify-center px-0 shrink-0",
|
||||||
|
STATUS_BADGE_STYLE[b.status]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Column 4: Requester */}
|
||||||
|
<td className="px-4 py-3 align-middle text-left text-muted-foreground truncate max-w-[12rem]" title={b.requested_by}>
|
||||||
|
{b.requested_by}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Column 5: Created At */}
|
||||||
|
<td className="px-4 py-3 align-middle text-center text-muted-foreground whitespace-nowrap" title={formatAbsolute(b.created_at, locale)}>
|
||||||
|
{formatRelative(b.created_at, now, locale)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Column 6: Duration */}
|
||||||
|
<td className="px-4 py-3 align-middle text-center font-mono text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDuration(b.created_at, b.finished_at, isZh) || "—"}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Column 7: Action */}
|
||||||
|
<td className="px-4 py-3 align-middle text-right whitespace-nowrap">
|
||||||
|
<div className="flex items-center justify-end gap-1.5 py-0.5">
|
||||||
|
{/* Cancel Button */}
|
||||||
|
{(b.status === "pending" || b.status === "building") && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCancel(b.id)}
|
||||||
|
disabled={cancellingId === b.id}
|
||||||
|
className="h-7 px-2 text-[11px] gap-1 font-semibold text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
{cancellingId === b.id ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{t("cancel_build_btn")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Log Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveLogBuildId(activeLogBuildId === b.id ? null : b.id)}
|
||||||
|
className={cn(
|
||||||
|
"h-7 px-2 text-[11px] gap-1 font-semibold transition-colors",
|
||||||
|
activeLogBuildId === b.id
|
||||||
|
? "bg-primary/10 text-primary hover:bg-primary/15"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Terminal className="h-3 w-3" />
|
||||||
|
<span>{t("view_logs_btn")}</span>
|
||||||
|
<ChevronDown className={cn("h-3 w-3 transition-transform duration-200", activeLogBuildId === b.id && "rotate-180")} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Inline SSE Log Console */}
|
||||||
|
{activeLogBuildId === b.id && (
|
||||||
|
<tr className="bg-muted/5 hover:bg-muted/5">
|
||||||
|
<td colSpan={7} className="p-0 border-t border-border">
|
||||||
|
<div className="px-4 pb-4 pt-1 bg-muted/10">
|
||||||
|
<div className="h-[300px] flex flex-col mt-2">
|
||||||
|
<LogConsole url={buildLogsStreamURL(config.apiBase, b.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{filteredBuilds.length > PAGE_SIZE && (
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
total={filteredBuilds.length}
|
||||||
|
onChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user