diff --git a/backend/Dockerfile b/backend/Dockerfile index 9e65775..c55b11d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,17 +9,11 @@ WORKDIR /app COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv RUN ( \ - sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \ - sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \ - sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null \ - ) || ( \ - sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ - sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \ - sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || true \ - ) + sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ + sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list 2>/dev/null \ + ) || true -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN apt-get -o Acquire::Retries=5 update && apt-get install -y --no-install-recommends \ curl \ ca-certificates \ gcc \ diff --git a/backend/src/backend/schedule_runs.py b/backend/src/backend/schedule_runs.py index 8c4bd6a..ea3357d 100644 --- a/backend/src/backend/schedule_runs.py +++ b/backend/src/backend/schedule_runs.py @@ -1,9 +1,20 @@ from __future__ import annotations +import asyncio from datetime import UTC, datetime from typing import Any, Literal +from urllib.parse import quote -from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status +from fastapi import ( + APIRouter, + Depends, + Header, + HTTPException, + Query, + Request, + Response, + status, +) from pydantic import Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.db.models import ( ScheduleNodeRuns, ScheduleRuns, + StorageObjects, ) from backend.dependencies import ( RequestContext, @@ -149,6 +161,75 @@ async def _visible_run( return item +async def _visible_node_run( + run_id: str, + node_run_id: str, + context: RequestContext, + session: AsyncSession, +) -> ScheduleNodeRuns: + await _visible_run(run_id, context, session) + item = await session.scalar( + select(ScheduleNodeRuns).where( + ScheduleNodeRuns.node_run_id == node_run_id, + ScheduleNodeRuns.run_id == run_id, + ) + ) + if item is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule node run not found") + return item + + +async def _visible_artifact( + storage_object_id: str | None, + *, + usage_type: Literal["run_log", "run_result"], + context: RequestContext, + session: AsyncSession, +) -> StorageObjects | None: + if storage_object_id is None: + return None + item = await session.scalar( + select(StorageObjects).where( + StorageObjects.storage_object_id == storage_object_id, + StorageObjects.workspace_id == context.workspace.workspace_id, + StorageObjects.usage_type == usage_type, + StorageObjects.object_status == "available", + StorageObjects.is_deleted == 0, + ) + ) + if ( + item is None + or item.storage_backend != "rustfs" + or not item.bucket_name + or not item.object_key + ): + return None + + return item + + +def _artifact_payload(item: StorageObjects | None, *, url: str) -> dict[str, Any] | None: + if item is None: + return None + return { + "url": url, + "file_name": item.file_name, + "mime_type": item.mime_type, + "size_bytes": item.size_bytes, + } + + +async def _artifact_bytes( + item: StorageObjects, + request: Request, +) -> bytes: + return await asyncio.to_thread( + request.app.state.object_store.get_bytes, + bucket_name=item.bucket_name, + object_key=item.object_key, + ) + + @router.post( "/api/v1/schedules/{schedule_id}/run", status_code=status.HTTP_202_ACCEPTED, @@ -240,3 +321,101 @@ async def get_schedule_run( "data": await run_detail(item, session), "meta": {}, } + + +@router.get( + "/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/artifacts" +) +async def get_schedule_node_run_artifacts( + run_id: str, + node_run_id: str, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + node_run = await _visible_node_run(run_id, node_run_id, context, session) + log_artifact = await _visible_artifact( + node_run.logs_object_id, + usage_type="run_log", + context=context, + session=session, + ) + result_artifact = await _visible_artifact( + node_run.result_object_id, + usage_type="run_result", + context=context, + session=session, + ) + base_path = f"/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}" + workspace_query = f"workspace_id={context.workspace.workspace_id}" + return { + "request_id": context.request_id, + "data": { + "run_id": run_id, + "node_run_id": node_run_id, + "log": _artifact_payload( + log_artifact, + url=f"{base_path}/logs?{workspace_query}", + ), + "result": _artifact_payload( + result_artifact, + url=f"{base_path}/result?{workspace_query}", + ), + }, + "meta": {}, + } + + +@router.get( + "/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/logs" +) +async def read_schedule_node_run_logs( + run_id: str, + node_run_id: str, + request: Request, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> Response: + node_run = await _visible_node_run(run_id, node_run_id, context, session) + item = await _visible_artifact( + node_run.logs_object_id, + usage_type="run_log", + context=context, + session=session, + ) + if item is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule node log not found") + return Response( + content=await _artifact_bytes(item, request), + media_type=item.mime_type or "text/plain; charset=utf-8", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get( + "/api/v1/schedule-runs/{run_id}/node-runs/{node_run_id}/result" +) +async def download_schedule_node_run_result( + run_id: str, + node_run_id: str, + request: Request, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> Response: + node_run = await _visible_node_run(run_id, node_run_id, context, session) + item = await _visible_artifact( + node_run.result_object_id, + usage_type="run_result", + context=context, + session=session, + ) + if item is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "schedule node result not found") + encoded_name = quote(item.file_name, safe="") + return Response( + content=await _artifact_bytes(item, request), + media_type=item.mime_type or "application/octet-stream", + headers={ + "Cache-Control": "no-store", + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}", + }, + ) diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index 81b45c6..7349fc8 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -253,5 +253,7 @@ export function useApi(): WorkspaceBoundApi { runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId), listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input), getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId), + getScheduleNodeRunArtifacts: (runId, nodeRunId) => + rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId), }), [workspaceId]); } diff --git a/frontend/app/features/schedules/SchedulePage.tsx b/frontend/app/features/schedules/SchedulePage.tsx index 1a24931..d96b57f 100644 --- a/frontend/app/features/schedules/SchedulePage.tsx +++ b/frontend/app/features/schedules/SchedulePage.tsx @@ -16,6 +16,8 @@ import { type ScheduleArtifact, type ScheduleEdge, type ScheduleNode, + type ScheduleNodeRun, + type ScheduleRunDetail, type ScheduleRunSummary, } from "../../services/api"; @@ -129,6 +131,18 @@ const RUN_STATUS_LABELS: Record = { timed_out: "已超时", }; +const NODE_STATUS_LABELS: Record = { + ...RUN_STATUS_LABELS, + skipped: "已跳过", +}; + +const TRIGGER_TYPE_LABELS: Record = { + manual: "手动触发", + cron: "Cron 定时", + api: "API 触发", + retry: "失败重试", +}; + function formatDuration(value: number | null): string { if (value === null) return "—"; if (value < 1000) return `${value} ms`; @@ -136,6 +150,12 @@ function formatDuration(value: number | null): string { return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`; } +function formatSize(value: number): string { + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + function parseObject(text: string, label: string): Record { let value: unknown; try { @@ -1399,6 +1419,7 @@ export default function SchedulePage({ { @@ -1562,15 +1583,203 @@ export default function SchedulePage({ function RunHistory({ runs, + nodes, loading, disabled, onRefresh, }: { runs: ScheduleRunSummary[]; + nodes: ScheduleNode[]; loading: boolean; disabled: boolean; onRefresh: () => void; }) { + const api = useApi(); + const [expandedRunId, setExpandedRunId] = useState(null); + const [runDetails, setRunDetails] = useState>({}); + const [detailLoadingId, setDetailLoadingId] = useState(null); + const [detailErrors, setDetailErrors] = useState>({}); + const [openLogNodeRunIds, setOpenLogNodeRunIds] = useState>( + () => new Set(), + ); + const [logStates, setLogStates] = useState>({}); + const [artifactBusyKey, setArtifactBusyKey] = useState(null); + const [artifactErrors, setArtifactErrors] = useState>({}); + + const expandedSummary = runs.find((run) => run.run_id === expandedRunId); + + useEffect(() => { + if (expandedRunId && !expandedSummary) { + setExpandedRunId(null); + setOpenLogNodeRunIds(new Set()); + } + }, [expandedRunId, expandedSummary]); + + useEffect(() => { + const runId = expandedRunId; + if (!runId) return undefined; + let cancelled = false; + setDetailLoadingId(runId); + setDetailErrors((current) => { + const next = { ...current }; + delete next[runId]; + return next; + }); + api.getScheduleRun(runId) + .then((detail) => { + if (!cancelled) { + setRunDetails((current) => ({ ...current, [runId]: detail })); + } + }) + .catch((error: unknown) => { + if (!cancelled) { + setDetailErrors((current) => ({ + ...current, + [runId]: error instanceof Error ? error.message : "运行详情加载失败", + })); + } + }) + .finally(() => { + if (!cancelled) setDetailLoadingId(null); + }); + return () => { + cancelled = true; + }; + }, [api, expandedRunId, expandedSummary?.state_version]); + + const toggleRun = (runId: string): void => { + setExpandedRunId((current) => current === runId ? null : runId); + setOpenLogNodeRunIds(new Set()); + }; + + const toggleLog = async (runId: string, nodeRunId: string): Promise => { + if (openLogNodeRunIds.has(nodeRunId)) { + setOpenLogNodeRunIds((current) => { + const next = new Set(current); + next.delete(nodeRunId); + return next; + }); + return; + } + setOpenLogNodeRunIds((current) => new Set(current).add(nodeRunId)); + if (logStates[nodeRunId]?.content) return; + + setLogStates((current) => ({ + ...current, + [nodeRunId]: { + loading: true, + content: null, + fileName: null, + error: null, + }, + })); + try { + const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId); + if (!artifacts.log) throw new Error("本次节点执行没有可用日志"); + const response = await fetch(artifacts.log.url, { + credentials: "same-origin", + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`日志读取失败(HTTP ${response.status})`); + } + const content = await response.text(); + setLogStates((current) => ({ + ...current, + [nodeRunId]: { + loading: false, + content, + fileName: artifacts.log?.file_name ?? null, + error: null, + }, + })); + } catch (error) { + setLogStates((current) => ({ + ...current, + [nodeRunId]: { + loading: false, + content: null, + fileName: null, + error: error instanceof Error ? error.message : "日志读取失败", + }, + })); + } + }; + + const downloadResult = async ( + runId: string, + nodeRunId: string, + ): Promise => { + const busyKey = `${nodeRunId}:result`; + setArtifactBusyKey(busyKey); + setArtifactErrors((current) => { + const next = { ...current }; + delete next[nodeRunId]; + return next; + }); + try { + const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId); + if (!artifacts.result) throw new Error("本次节点执行没有可下载结果"); + const link = document.createElement("a"); + link.href = artifacts.result.url; + link.download = artifacts.result.file_name; + document.body.appendChild(link); + link.click(); + link.remove(); + } catch (error) { + setArtifactErrors((current) => ({ + ...current, + [nodeRunId]: error instanceof Error ? error.message : "结果下载失败", + })); + } finally { + setArtifactBusyKey(null); + } + }; + + const downloadLog = async ( + runId: string, + nodeRunId: string, + ): Promise => { + const busyKey = `${nodeRunId}:log-download`; + setArtifactBusyKey(busyKey); + setArtifactErrors((current) => { + const next = { ...current }; + delete next[nodeRunId]; + return next; + }); + try { + const artifacts = await api.getScheduleNodeRunArtifacts(runId, nodeRunId); + if (!artifacts.log) throw new Error("本次节点执行没有可下载日志"); + const response = await fetch(artifacts.log.url, { + credentials: "same-origin", + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`日志下载失败(HTTP ${response.status})`); + } + const objectUrl = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = artifacts.log.file_name; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(objectUrl); + } catch (error) { + setArtifactErrors((current) => ({ + ...current, + [nodeRunId]: error instanceof Error ? error.message : "日志下载失败", + })); + } finally { + setArtifactBusyKey(null); + } + }; + return (
@@ -1593,19 +1802,174 @@ function RunHistory({ ) : runs.length === 0 ? (

点击右上角“立即运行”后,这里会显示状态和耗时。

) : ( - runs.map((run) => ( -
- -
- {RUN_STATUS_LABELS[run.run_status]} - - {formatTime(run.queued_at)} · {formatDuration(run.duration_ms)} - - {run.error_message &&

{run.error_message}

} -
- {run.run_id.slice(-8)} -
- )) + runs.map((run) => { + const expanded = expandedRunId === run.run_id; + const detail = runDetails[run.run_id]; + return ( +
+
+ + + {RUN_STATUS_LABELS[run.run_status]} + + {formatTime(run.queued_at)} · {formatDuration(run.duration_ms)} + + {run.error_message && {run.error_message}} + + {run.run_id.slice(-8)} + +
+ + {expanded && ( + <> + +
+
+ {detailLoadingId === run.run_id && !detail ? ( +

正在加载运行详情…

+ ) : detailErrors[run.run_id] ? ( +

+ {detailErrors[run.run_id]} +

+ ) : detail ? ( + <> +
+
触发方式
{TRIGGER_TYPE_LABELS[detail.trigger_type]}
+
工作流版本
v{detail.workflow_version}
+
开始时间
{formatTime(detail.started_at)}
+
完成时间
{formatTime(detail.finished_at)}
+
+ {(detail.error_code || detail.error_message) && ( +
+ {detail.error_code && {detail.error_code}} + {detail.error_message &&

{detail.error_message}

} +
+ )} +
+ {detail.node_runs.length === 0 ? ( +

节点尚未开始执行

+ ) : detail.node_runs.map((nodeRun) => { + const node = nodes.find((item) => item.node_id === nodeRun.node_id); + const logState = logStates[nodeRun.node_run_id]; + const logOpen = openLogNodeRunIds.has(nodeRun.node_run_id); + return ( +
+
+ + {node?.node_name ?? nodeRun.node_id.slice(-8)} + {NODE_STATUS_LABELS[nodeRun.node_status]} +
+
+ 第 {nodeRun.attempt_no} 次 + {formatDuration(nodeRun.duration_ms)} + 退出码 {nodeRun.exit_code ?? "—"} +
+ {nodeRun.message &&

{nodeRun.message}

} +
+ + + +
+ {artifactErrors[nodeRun.node_run_id] && ( +

+ {artifactErrors[nodeRun.node_run_id]} +

+ )} + {logOpen && ( +
+ {logState?.fileName && ( +
+ {logState.fileName} + + {formatSize(new TextEncoder().encode(logState.content ?? "").length)} + +
+ )} + {logState?.error ? ( +

{logState.error}

+ ) : ( +
{logState?.content ?? "正在读取日志…"}
+ )} +
+ )} +
+ ); + })} +
+ + ) : null} +
+
+ + )} + + ); + }) )} diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 86f0e50..6550dbe 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -745,6 +745,20 @@ export type ScheduleRunDetail = ScheduleRunSummary & { node_runs: ScheduleNodeRun[]; }; +export type ScheduleRunArtifact = { + url: string; + file_name: string; + mime_type: string | null; + size_bytes: number; +}; + +export type ScheduleNodeRunArtifacts = { + run_id: string; + node_run_id: string; + log: ScheduleRunArtifact | null; + result: ScheduleRunArtifact | null; +}; + export async function listSchedules(workspaceId: string): Promise { return apiRequest("/api/v1/schedules", {}, workspaceId); } @@ -1055,6 +1069,18 @@ export async function getScheduleRun( ); } +export async function getScheduleNodeRunArtifacts( + workspaceId: string, + runId: string, + nodeRunId: string, +): Promise { + return apiRequest( + `/api/v1/schedule-runs/${runId}/node-runs/${nodeRunId}/artifacts`, + {}, + workspaceId, + ); +} + // ---------------------------------------------------------------------------- // Workspace-bound API surface. // @@ -1177,4 +1203,8 @@ export type WorkspaceBoundApi = { input?: Parameters[1], ) => Promise; getScheduleRun: (runId: string) => Promise; + getScheduleNodeRunArtifacts: ( + runId: string, + nodeRunId: string, + ) => Promise; }; diff --git a/frontend/app/styles/schedule.css b/frontend/app/styles/schedule.css index ce245f3..844d69a 100644 --- a/frontend/app/styles/schedule.css +++ b/frontend/app/styles/schedule.css @@ -700,7 +700,7 @@ .schedule-right { display: grid; overflow: hidden; - grid-template-rows: minmax(0, 1fr) 236px; + grid-template-rows: repeat(2, minmax(0, 1fr)); } .schedule-inspector-scroll { @@ -978,35 +978,53 @@ } .schedule-run-card { - display: grid; - align-items: start; - gap: 7px; - padding: 8px; + flex: 0 0 auto; + overflow: hidden; border: 1px solid #e1e8ef; border-radius: 6px; background: #fff; - grid-template-columns: 8px minmax(0, 1fr) auto; } -.schedule-run-card > div { +.schedule-run-card.is-expanded { + border-color: #b9d6f1; + box-shadow: 0 3px 10px rgb(38 117 185 / 8%); +} + +.schedule-run-summary { + display: grid; + width: 100%; + align-items: center; + gap: 7px; + padding: 8px; + border: 0; + color: inherit; + background: #fff; + grid-template-columns: 8px minmax(0, 1fr) auto auto; + text-align: left; +} + +.schedule-run-summary:hover { + background: #f7fbff; +} + +.schedule-run-summary-copy { display: flex; min-width: 0; flex-direction: column; gap: 2px; } -.schedule-run-card strong { +.schedule-run-summary-copy strong { color: #425970; font-size: 10px; } -.schedule-run-card small { +.schedule-run-summary-copy small { color: #8b99a8; font-size: 8px; } -.schedule-run-card p { - margin: 2px 0 0; +.schedule-run-summary-copy > span { overflow: hidden; color: #b65353; font-size: 8px; @@ -1015,12 +1033,340 @@ white-space: nowrap; } -.schedule-run-card code { +.schedule-run-summary code { color: #8b9bad; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 8px; } +.schedule-run-result-button { + min-height: 27px; + padding: 0 9px; + border: 1px solid #bcd4e9; + border-radius: 5px; + color: #2374ba; + background: #f5faff; + cursor: pointer; + font-size: 9px; + font-weight: 650; + white-space: nowrap; +} + +.schedule-run-result-button:hover { + border-color: #7eafe0; + background: #eaf5ff; +} + +.schedule-run-chevron { + display: grid; + margin-top: 1px; + place-items: center; + color: #8092a5; + transition: transform .16s ease; +} + +.schedule-run-chevron.is-open { + transform: rotate(90deg); +} + +.schedule-run-detail { + min-height: 0; + padding: 18px; + overflow: auto; + background: #f8fbfe; +} + +.schedule-run-result-backdrop { + position: fixed; + z-index: 40; + inset: 0; + border: 0; + background: rgb(10 27 45 / 46%); + cursor: default; + backdrop-filter: blur(2px); +} + +.schedule-run-result-modal { + position: fixed; + z-index: 41; + top: 50%; + left: 50%; + display: grid; + width: min(780px, calc(100vw - 48px)); + max-height: min(760px, calc(100vh - 64px)); + overflow: hidden; + border: 1px solid #d8e2eb; + border-radius: 12px; + background: #fff; + box-shadow: 0 24px 80px rgb(8 27 48 / 30%); + grid-template-rows: auto minmax(0, 1fr); + transform: translate(-50%, -50%); + animation: modal-in .18s ease-out; +} + +.schedule-run-result-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 17px 20px; + border-bottom: 1px solid #e3eaf0; + background: #fff; +} + +.schedule-run-result-modal__header > div { + display: grid; + min-width: 0; + align-items: center; + gap: 5px 10px; + grid-template-columns: auto auto; +} + +.schedule-run-result-modal__header span { + color: #1f344a; + font-size: 17px; + font-weight: 700; +} + +.schedule-run-result-modal__header strong { + justify-self: start; + padding: 3px 8px; + border-radius: 11px; + color: #17805a; + background: #e8f7f1; + font-size: 10px; +} + +.schedule-run-result-modal__header code { + overflow: hidden; + color: #8291a1; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 10px; + grid-column: 1 / -1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-run-result-modal .schedule-run-meta { + margin-bottom: 14px; + gap: 10px 18px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.schedule-run-result-modal .schedule-run-meta dt { + margin-bottom: 4px; + font-size: 10px; +} + +.schedule-run-result-modal .schedule-run-meta dd { + font-size: 12px; +} + +.schedule-run-result-modal .schedule-node-run-list { + gap: 10px; +} + +.schedule-run-result-modal .schedule-node-run { + padding: 12px; + border-radius: 7px; +} + +.schedule-run-result-modal .schedule-node-run > header strong { + font-size: 13px; +} + +.schedule-run-result-modal .schedule-node-run > header > span:last-child, +.schedule-run-result-modal .schedule-node-run-meta, +.schedule-run-result-modal .schedule-node-run > p { + font-size: 10px; +} + +.schedule-run-result-modal .schedule-node-run-actions button { + padding: 6px 10px; + font-size: 10px; +} + +.schedule-run-result-modal .schedule-node-log > header { + padding: 8px 10px; + font-size: 10px; +} + +.schedule-run-result-modal .schedule-node-log pre, +.schedule-run-result-modal .schedule-node-log > p { + max-height: 260px; + padding: 11px; + font-size: 11px; +} + +.run-detail-message { + margin: 0; + padding: 8px 4px; + color: #8796a6; + font-size: 9px; + line-height: 1.5; + text-align: center; +} + +.run-detail-message.is-error { + color: #bf5656; +} + +.schedule-run-meta { + display: grid; + margin: 0 0 8px; + gap: 6px 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.schedule-run-meta > div { + min-width: 0; +} + +.schedule-run-meta dt { + margin-bottom: 1px; + color: #95a2af; + font-size: 8px; +} + +.schedule-run-meta dd { + margin: 0; + overflow: hidden; + color: #41566c; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-run-error { + margin-bottom: 8px; + padding: 7px; + border: 1px solid #f0cece; + border-radius: 5px; + color: #a94f4f; + background: #fff6f6; +} + +.schedule-run-error code { + font-size: 8px; + font-weight: 700; +} + +.schedule-run-error p { + margin: 3px 0 0; + font-size: 8px; + line-height: 1.45; +} + +.schedule-node-run-list { + display: flex; + flex-direction: column; + gap: 7px; +} + +.schedule-node-run { + padding: 7px; + border: 1px solid #dde7f0; + border-radius: 5px; + background: #fff; +} + +.schedule-node-run > header { + display: grid; + align-items: start; + gap: 6px; + grid-template-columns: 8px minmax(0, 1fr) auto; +} + +.schedule-node-run > header strong { + overflow: hidden; + color: #40566c; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.schedule-node-run > header > span:last-child { + color: #71869a; + font-size: 8px; +} + +.schedule-node-run-meta { + display: flex; + flex-wrap: wrap; + gap: 3px 8px; + margin: 5px 0 0 14px; + color: #8a98a7; + font-size: 8px; +} + +.schedule-node-run > p { + margin: 5px 0 0 14px; + color: #6c7e90; + font-size: 8px; + line-height: 1.45; +} + +.schedule-node-run-actions { + display: flex; + gap: 6px; + margin: 7px 0 0 14px; +} + +.schedule-node-run-actions button { + padding: 4px 7px; + border: 1px solid #cbddeb; + border-radius: 4px; + color: #2878bd; + background: #f5faff; + cursor: pointer; + font-size: 8px; +} + +.schedule-node-run-actions button:disabled { + cursor: not-allowed; + opacity: .45; +} + +.schedule-artifact-error { + color: #b65353 !important; +} + +.schedule-node-log { + margin: 7px 0 0 14px; + overflow: hidden; + border: 1px solid #dce5ed; + border-radius: 4px; + background: #17212b; +} + +.schedule-node-log > header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 7px; + border-bottom: 1px solid #33414e; + color: #c8d2dc; + font-size: 8px; +} + +.schedule-node-log > header small { + color: #8292a1; +} + +.schedule-node-log pre, +.schedule-node-log > p { + max-height: 210px; + margin: 0; + padding: 7px; + overflow: auto; + color: #d9e2ea; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 8px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + .run-status-dot { width: 7px; height: 7px; @@ -1048,6 +1394,10 @@ background: #d75b5b; } +.run-status-dot.is-skipped { + background: #a9b4bf; +} + .node-inspector-title { flex-direction: row !important; align-items: center; diff --git a/migrations/versions/d4e5f6a7b8c9_reconcile_legacy_remote_revision.py b/migrations/versions/d4e5f6a7b8c9_reconcile_legacy_remote_revision.py new file mode 100644 index 0000000..8617f4c --- /dev/null +++ b/migrations/versions/d4e5f6a7b8c9_reconcile_legacy_remote_revision.py @@ -0,0 +1,27 @@ +"""reconcile the legacy remote database revision + +Revision ID: d4e5f6a7b8c9 +Revises: a2b3c4d5e6f7 +Create Date: 2026-08-05 15:30:00 + +The shared development database was stamped with this revision by an older +migration history. The corresponding file was lost while branches were +merged. Keeping the marker in the active chain lets Alembic safely continue +without rewriting the existing database or replaying the baseline migration. +""" + +from collections.abc import Sequence + + +revision: str = "d4e5f6a7b8c9" +down_revision: str | Sequence[str] | None = "a2b3c4d5e6f7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Preserve the already-applied legacy revision marker.""" + + +def downgrade() -> None: + """The compatibility marker has no schema operation to reverse.""" diff --git a/migrations/versions/e5f6a7b8c9d0_ensure_demo_login.py b/migrations/versions/e5f6a7b8c9d0_ensure_demo_login.py new file mode 100644 index 0000000..9e2504c --- /dev/null +++ b/migrations/versions/e5f6a7b8c9d0_ensure_demo_login.py @@ -0,0 +1,158 @@ +"""ensure the self-hosted demo login remains available + +Revision ID: e5f6a7b8c9d0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-08-05 15:31:00 +""" + +from collections.abc import Sequence +import os + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +from common.auth.passwords import hash_password + + +revision: str = "e5f6a7b8c9d0" +down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +DISABLED_PASSWORD = "demo-login-disabled" +SEEDED_USERS = ( + ( + "0000000000RF6FG1SDBXG59S13", + "admin-zhang", + "张三", + "0000000000000000000000000A", + ), + ( + "0000000000H2QYCGPCWQM1JSGS", + "admin-li", + "李四", + "0000000000000000000000000A", + ), + ( + "0000000000RWG40ESZPGJT629J", + "dev-wang", + "王五", + "0000000000000000000000000B", + ), + ( + "00000000004CQV7WASJA6N6FW4", + "dev-zhao", + "赵六", + "0000000000000000000000000B", + ), +) + + +def _create_users_table() -> None: + op.create_table( + "users", + sa.Column("user_id", mysql.CHAR(length=26), nullable=False), + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("display_name", sa.String(length=100), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column( + "status", + sa.String(length=16), + server_default=sa.text("'active'"), + nullable=False, + comment="active/disabled/locked", + ), + sa.Column( + "created_at", + mysql.DATETIME(fsp=3), + server_default=sa.text("CURRENT_TIMESTAMP(3)"), + nullable=False, + ), + sa.Column( + "updated_at", + mysql.DATETIME(fsp=3), + server_default=sa.text( + "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + ), + nullable=False, + ), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("platform_role_id", mysql.CHAR(length=26), nullable=True), + sa.Column("avatar_uri", sa.String(length=1000), nullable=True), + sa.Column("last_login_at", mysql.DATETIME(fsp=3), nullable=True), + sa.Column( + "is_deleted", + mysql.TINYINT(display_width=1), + server_default=sa.text("0"), + nullable=False, + ), + sa.Column("deleted_at", mysql.DATETIME(fsp=3), nullable=True), + sa.PrimaryKeyConstraint("user_id"), + comment="平台用户", + ) + op.create_index("fk_users_platform_role", "users", ["platform_role_id"]) + op.create_index("idx_users_status", "users", ["status"]) + op.create_index("uk_users_email", "users", ["email"], unique=True) + op.create_index("uk_users_username", "users", ["username"], unique=True) + + +def upgrade() -> None: + connection = op.get_bind() + if not sa.inspect(connection).has_table("users"): + _create_users_table() + + users = sa.table( + "users", + sa.column("user_id", sa.String), + sa.column("username", sa.String), + sa.column("display_name", sa.String), + sa.column("password_hash", sa.String), + sa.column("status", sa.String), + sa.column("email", sa.String), + sa.column("platform_role_id", sa.String), + ) + existing = { + row.username: row.password_hash + for row in connection.execute( + sa.select(users.c.username, users.c.password_hash).where( + users.c.username.in_([user[1] for user in SEEDED_USERS]) + ) + ) + } + password_hash = hash_password( + os.environ.get("INITIAL_ADMIN_PASSWORD", "admin12345") + ) + + for user_id, username, display_name, role_id in SEEDED_USERS: + if username not in existing: + connection.execute( + users.insert().values( + user_id=user_id, + username=username, + display_name=display_name, + password_hash=password_hash, + status="active", + email=f"{username}@model-platform.local", + platform_role_id=role_id, + ) + ) + continue + + if existing[username] in {None, "", DISABLED_PASSWORD}: + connection.execute( + users.update() + .where(users.c.username == username) + .values(password_hash=password_hash) + ) + + connection.execute( + users.update() + .where(users.c.username == "admin-zhang") + .values(status="active") + ) + + +def downgrade() -> None: + """Do not remove or disable accounts that may contain user data."""