添加查看调度运行结果

This commit is contained in:
Winnie
2026-08-05 16:34:35 +08:00
parent c263ae6a5f
commit 30cde4a56c
8 changed files with 1140 additions and 36 deletions
+4 -10
View File
@@ -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 \
+180 -1
View File
@@ -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}",
},
)
+2
View File
@@ -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]);
}
+377 -13
View File
@@ -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<ScheduleRunSummary["run_status"], string> = {
timed_out: "已超时",
};
const NODE_STATUS_LABELS: Record<ScheduleNodeRun["node_status"], string> = {
...RUN_STATUS_LABELS,
skipped: "已跳过",
};
const TRIGGER_TYPE_LABELS: Record<ScheduleRunSummary["trigger_type"], string> = {
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<string, unknown> {
let value: unknown;
try {
@@ -1399,6 +1419,7 @@ export default function SchedulePage({
</div>
<RunHistory
runs={runs}
nodes={schedule?.nodes ?? []}
loading={runsLoading}
disabled={!schedule || Boolean(busy)}
onRefresh={() => {
@@ -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<string | null>(null);
const [runDetails, setRunDetails] = useState<Record<string, ScheduleRunDetail>>({});
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
const [detailErrors, setDetailErrors] = useState<Record<string, string>>({});
const [openLogNodeRunIds, setOpenLogNodeRunIds] = useState<Set<string>>(
() => new Set(),
);
const [logStates, setLogStates] = useState<Record<string, {
loading: boolean;
content: string | null;
fileName: string | null;
error: string | null;
}>>({});
const [artifactBusyKey, setArtifactBusyKey] = useState<string | null>(null);
const [artifactErrors, setArtifactErrors] = useState<Record<string, string>>({});
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<void> => {
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<void> => {
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<void> => {
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 (
<section className="schedule-run-history">
<header>
@@ -1593,19 +1802,174 @@ function RunHistory({
) : runs.length === 0 ? (
<p></p>
) : (
runs.map((run) => (
<article className="schedule-run-card" key={run.run_id}>
<span className={`run-status-dot is-${run.run_status}`} />
<div>
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<small>
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && <p>{run.error_message}</p>}
</div>
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
</article>
))
runs.map((run) => {
const expanded = expandedRunId === run.run_id;
const detail = runDetails[run.run_id];
return (
<article
className={`schedule-run-card${expanded ? " is-expanded" : ""}`}
key={run.run_id}
>
<div
className="schedule-run-summary"
>
<span className={`run-status-dot is-${run.run_status}`} />
<span className="schedule-run-summary-copy">
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<small>
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
</small>
{run.error_message && <span>{run.error_message}</span>}
</span>
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
<button
className="schedule-run-result-button"
type="button"
aria-haspopup="dialog"
onClick={() => toggleRun(run.run_id)}
>
</button>
</div>
{expanded && (
<>
<button
className="schedule-run-result-backdrop"
type="button"
aria-label="关闭运行结果"
onClick={() => toggleRun(run.run_id)}
/>
<section
className="schedule-run-result-modal"
role="dialog"
aria-modal="true"
aria-label="调度运行结果"
>
<header className="schedule-run-result-modal__header">
<div>
<span></span>
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
<code>{run.run_id}</code>
</div>
<button
className="icon-button"
type="button"
aria-label="关闭运行结果"
onClick={() => toggleRun(run.run_id)}
>
<Icon name="close" size={16} />
</button>
</header>
<div className="schedule-run-detail">
{detailLoadingId === run.run_id && !detail ? (
<p className="run-detail-message"></p>
) : detailErrors[run.run_id] ? (
<p className="run-detail-message is-error">
{detailErrors[run.run_id]}
</p>
) : detail ? (
<>
<dl className="schedule-run-meta">
<div><dt></dt><dd>{TRIGGER_TYPE_LABELS[detail.trigger_type]}</dd></div>
<div><dt></dt><dd>v{detail.workflow_version}</dd></div>
<div><dt></dt><dd>{formatTime(detail.started_at)}</dd></div>
<div><dt></dt><dd>{formatTime(detail.finished_at)}</dd></div>
</dl>
{(detail.error_code || detail.error_message) && (
<div className="schedule-run-error">
{detail.error_code && <code>{detail.error_code}</code>}
{detail.error_message && <p>{detail.error_message}</p>}
</div>
)}
<div className="schedule-node-run-list">
{detail.node_runs.length === 0 ? (
<p className="run-detail-message"></p>
) : 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 (
<article className="schedule-node-run" key={nodeRun.node_run_id}>
<header>
<span className={`run-status-dot is-${nodeRun.node_status}`} />
<strong>{node?.node_name ?? nodeRun.node_id.slice(-8)}</strong>
<span>{NODE_STATUS_LABELS[nodeRun.node_status]}</span>
</header>
<div className="schedule-node-run-meta">
<span> {nodeRun.attempt_no} </span>
<span>{formatDuration(nodeRun.duration_ms)}</span>
<span>退 {nodeRun.exit_code ?? "—"}</span>
</div>
{nodeRun.message && <p>{nodeRun.message}</p>}
<div className="schedule-node-run-actions">
<button
type="button"
disabled={!nodeRun.logs_object_id || logState?.loading}
onClick={() => void toggleLog(run.run_id, nodeRun.node_run_id)}
>
{logState?.loading ? "读取中…" : logOpen ? "收起日志" : "查看日志"}
</button>
<button
type="button"
disabled={
!nodeRun.logs_object_id
|| artifactBusyKey === `${nodeRun.node_run_id}:log-download`
}
onClick={() => void downloadLog(run.run_id, nodeRun.node_run_id)}
>
{artifactBusyKey === `${nodeRun.node_run_id}:log-download`
? "准备中…"
: "下载日志"}
</button>
<button
type="button"
disabled={
!nodeRun.result_object_id
|| artifactBusyKey === `${nodeRun.node_run_id}:result`
}
onClick={() => void downloadResult(run.run_id, nodeRun.node_run_id)}
>
{artifactBusyKey === `${nodeRun.node_run_id}:result`
? "准备中…"
: "下载结果"}
</button>
</div>
{artifactErrors[nodeRun.node_run_id] && (
<p className="schedule-artifact-error">
{artifactErrors[nodeRun.node_run_id]}
</p>
)}
{logOpen && (
<div className="schedule-node-log">
{logState?.fileName && (
<header>
<span>{logState.fileName}</span>
<small>
{formatSize(new TextEncoder().encode(logState.content ?? "").length)}
</small>
</header>
)}
{logState?.error ? (
<p>{logState.error}</p>
) : (
<pre>{logState?.content ?? "正在读取日志…"}</pre>
)}
</div>
)}
</article>
);
})}
</div>
</>
) : null}
</div>
</section>
</>
)}
</article>
);
})
)}
</div>
</section>
+30
View File
@@ -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<Schedule[]> {
return apiRequest<Schedule[]>("/api/v1/schedules", {}, workspaceId);
}
@@ -1055,6 +1069,18 @@ export async function getScheduleRun(
);
}
export async function getScheduleNodeRunArtifacts(
workspaceId: string,
runId: string,
nodeRunId: string,
): Promise<ScheduleNodeRunArtifacts> {
return apiRequest<ScheduleNodeRunArtifacts>(
`/api/v1/schedule-runs/${runId}/node-runs/${nodeRunId}/artifacts`,
{},
workspaceId,
);
}
// ----------------------------------------------------------------------------
// Workspace-bound API surface.
//
@@ -1177,4 +1203,8 @@ export type WorkspaceBoundApi = {
input?: Parameters<typeof listScheduleRuns>[1],
) => Promise<ScheduleRunSummary[]>;
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
getScheduleNodeRunArtifacts: (
runId: string,
nodeRunId: string,
) => Promise<ScheduleNodeRunArtifacts>;
};
+362 -12
View File
@@ -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;
@@ -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."""
@@ -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."""