添加查看调度运行结果
This commit is contained in:
+4
-10
@@ -9,17 +9,11 @@ WORKDIR /app
|
|||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||||
|
|
||||||
RUN ( \
|
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.tuna.tsinghua.edu.cn/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/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/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/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 \
|
|
||||||
)
|
|
||||||
|
|
||||||
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 \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
gcc \
|
gcc \
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Literal
|
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 pydantic import Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -11,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from common.db.models import (
|
from common.db.models import (
|
||||||
ScheduleNodeRuns,
|
ScheduleNodeRuns,
|
||||||
ScheduleRuns,
|
ScheduleRuns,
|
||||||
|
StorageObjects,
|
||||||
)
|
)
|
||||||
from backend.dependencies import (
|
from backend.dependencies import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
@@ -149,6 +161,75 @@ async def _visible_run(
|
|||||||
return item
|
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(
|
@router.post(
|
||||||
"/api/v1/schedules/{schedule_id}/run",
|
"/api/v1/schedules/{schedule_id}/run",
|
||||||
status_code=status.HTTP_202_ACCEPTED,
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
@@ -240,3 +321,101 @@ async def get_schedule_run(
|
|||||||
"data": await run_detail(item, session),
|
"data": await run_detail(item, session),
|
||||||
"meta": {},
|
"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}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -253,5 +253,7 @@ export function useApi(): WorkspaceBoundApi {
|
|||||||
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
|
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
|
||||||
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
|
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
|
||||||
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
|
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
|
||||||
|
getScheduleNodeRunArtifacts: (runId, nodeRunId) =>
|
||||||
|
rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId),
|
||||||
}), [workspaceId]);
|
}), [workspaceId]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
type ScheduleArtifact,
|
type ScheduleArtifact,
|
||||||
type ScheduleEdge,
|
type ScheduleEdge,
|
||||||
type ScheduleNode,
|
type ScheduleNode,
|
||||||
|
type ScheduleNodeRun,
|
||||||
|
type ScheduleRunDetail,
|
||||||
type ScheduleRunSummary,
|
type ScheduleRunSummary,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
|
||||||
@@ -129,6 +131,18 @@ const RUN_STATUS_LABELS: Record<ScheduleRunSummary["run_status"], string> = {
|
|||||||
timed_out: "已超时",
|
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 {
|
function formatDuration(value: number | null): string {
|
||||||
if (value === null) return "—";
|
if (value === null) return "—";
|
||||||
if (value < 1000) return `${value} ms`;
|
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`;
|
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> {
|
function parseObject(text: string, label: string): Record<string, unknown> {
|
||||||
let value: unknown;
|
let value: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -1399,6 +1419,7 @@ export default function SchedulePage({
|
|||||||
</div>
|
</div>
|
||||||
<RunHistory
|
<RunHistory
|
||||||
runs={runs}
|
runs={runs}
|
||||||
|
nodes={schedule?.nodes ?? []}
|
||||||
loading={runsLoading}
|
loading={runsLoading}
|
||||||
disabled={!schedule || Boolean(busy)}
|
disabled={!schedule || Boolean(busy)}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
@@ -1562,15 +1583,203 @@ export default function SchedulePage({
|
|||||||
|
|
||||||
function RunHistory({
|
function RunHistory({
|
||||||
runs,
|
runs,
|
||||||
|
nodes,
|
||||||
loading,
|
loading,
|
||||||
disabled,
|
disabled,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
runs: ScheduleRunSummary[];
|
runs: ScheduleRunSummary[];
|
||||||
|
nodes: ScheduleNode[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onRefresh: () => void;
|
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 (
|
return (
|
||||||
<section className="schedule-run-history">
|
<section className="schedule-run-history">
|
||||||
<header>
|
<header>
|
||||||
@@ -1593,19 +1802,174 @@ function RunHistory({
|
|||||||
) : runs.length === 0 ? (
|
) : runs.length === 0 ? (
|
||||||
<p>点击右上角“立即运行”后,这里会显示状态和耗时。</p>
|
<p>点击右上角“立即运行”后,这里会显示状态和耗时。</p>
|
||||||
) : (
|
) : (
|
||||||
runs.map((run) => (
|
runs.map((run) => {
|
||||||
<article className="schedule-run-card" key={run.run_id}>
|
const expanded = expandedRunId === run.run_id;
|
||||||
<span className={`run-status-dot is-${run.run_status}`} />
|
const detail = runDetails[run.run_id];
|
||||||
<div>
|
return (
|
||||||
<strong>{RUN_STATUS_LABELS[run.run_status]}</strong>
|
<article
|
||||||
<small>
|
className={`schedule-run-card${expanded ? " is-expanded" : ""}`}
|
||||||
{formatTime(run.queued_at)} · {formatDuration(run.duration_ms)}
|
key={run.run_id}
|
||||||
</small>
|
>
|
||||||
{run.error_message && <p>{run.error_message}</p>}
|
<div
|
||||||
</div>
|
className="schedule-run-summary"
|
||||||
<code title={run.run_id}>{run.run_id.slice(-8)}</code>
|
>
|
||||||
</article>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -745,6 +745,20 @@ export type ScheduleRunDetail = ScheduleRunSummary & {
|
|||||||
node_runs: ScheduleNodeRun[];
|
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[]> {
|
export async function listSchedules(workspaceId: string): Promise<Schedule[]> {
|
||||||
return apiRequest<Schedule[]>("/api/v1/schedules", {}, workspaceId);
|
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.
|
// Workspace-bound API surface.
|
||||||
//
|
//
|
||||||
@@ -1177,4 +1203,8 @@ export type WorkspaceBoundApi = {
|
|||||||
input?: Parameters<typeof listScheduleRuns>[1],
|
input?: Parameters<typeof listScheduleRuns>[1],
|
||||||
) => Promise<ScheduleRunSummary[]>;
|
) => Promise<ScheduleRunSummary[]>;
|
||||||
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
|
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
|
||||||
|
getScheduleNodeRunArtifacts: (
|
||||||
|
runId: string,
|
||||||
|
nodeRunId: string,
|
||||||
|
) => Promise<ScheduleNodeRunArtifacts>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -700,7 +700,7 @@
|
|||||||
.schedule-right {
|
.schedule-right {
|
||||||
display: grid;
|
display: grid;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
grid-template-rows: minmax(0, 1fr) 236px;
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-inspector-scroll {
|
.schedule-inspector-scroll {
|
||||||
@@ -978,35 +978,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schedule-run-card {
|
.schedule-run-card {
|
||||||
display: grid;
|
flex: 0 0 auto;
|
||||||
align-items: start;
|
overflow: hidden;
|
||||||
gap: 7px;
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #e1e8ef;
|
border: 1px solid #e1e8ef;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #fff;
|
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;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-run-card strong {
|
.schedule-run-summary-copy strong {
|
||||||
color: #425970;
|
color: #425970;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-run-card small {
|
.schedule-run-summary-copy small {
|
||||||
color: #8b99a8;
|
color: #8b99a8;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-run-card p {
|
.schedule-run-summary-copy > span {
|
||||||
margin: 2px 0 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #b65353;
|
color: #b65353;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
@@ -1015,12 +1033,340 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-run-card code {
|
.schedule-run-summary code {
|
||||||
color: #8b9bad;
|
color: #8b9bad;
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
font-size: 8px;
|
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 {
|
.run-status-dot {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -1048,6 +1394,10 @@
|
|||||||
background: #d75b5b;
|
background: #d75b5b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.run-status-dot.is-skipped {
|
||||||
|
background: #a9b4bf;
|
||||||
|
}
|
||||||
|
|
||||||
.node-inspector-title {
|
.node-inspector-title {
|
||||||
flex-direction: row !important;
|
flex-direction: row !important;
|
||||||
align-items: center;
|
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."""
|
||||||
Reference in New Issue
Block a user