chore:调度文件拆分
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { type ScheduleArtifact } from "../../services/api";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { shortHash } from "./utils";
|
||||
|
||||
export function ArtifactList({
|
||||
artifacts,
|
||||
keyword,
|
||||
onDrop,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onKeywordChange,
|
||||
}: {
|
||||
artifacts: ScheduleArtifact[];
|
||||
keyword: string;
|
||||
onDrop: (artifact: ScheduleArtifact, x: number, y: number) => void;
|
||||
onDoubleClick: (artifact: ScheduleArtifact) => void;
|
||||
onContextMenu: (event: React.MouseEvent, artifact: ScheduleArtifact) => void;
|
||||
onKeywordChange: (keyword: string) => void;
|
||||
}) {
|
||||
const filtered = keyword.trim()
|
||||
? artifacts.filter((item) => (
|
||||
item.script_name.toLowerCase().includes(keyword.trim().toLowerCase())
|
||||
|| item.version_label.toLowerCase().includes(keyword.trim().toLowerCase())
|
||||
))
|
||||
: artifacts;
|
||||
|
||||
return (
|
||||
<section className="schedule-panel schedule-artifacts-panel">
|
||||
<div className="schedule-panel__title">
|
||||
<div><strong>稳定版本脚本</strong><span>{artifacts.length}</span></div>
|
||||
</div>
|
||||
<label className="schedule-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
value={keyword}
|
||||
placeholder="搜索稳定版本"
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="artifact-tip">拖动卡片到画布,节点将保存 versions_id</p>
|
||||
<div className="artifact-list">
|
||||
{filtered.length ? filtered.map((artifact) => (
|
||||
<div
|
||||
className="artifact-card"
|
||||
draggable
|
||||
key={artifact.versions_id}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData(
|
||||
"application/x-model-platform-version",
|
||||
artifact.versions_id,
|
||||
);
|
||||
event.dataTransfer.setData("text/plain", artifact.versions_id);
|
||||
}}
|
||||
onContextMenu={(event) => onContextMenu(event, artifact)}
|
||||
onDoubleClick={() => onDoubleClick(artifact)}
|
||||
>
|
||||
<span className={`artifact-card__icon artifact-card__icon--${artifact.script_type}`}>
|
||||
<Icon
|
||||
name={artifact.script_type === "notebook" ? "notebook" : "python"}
|
||||
size={17}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{artifact.script_name}</strong>
|
||||
<small>
|
||||
{artifact.version_label} · {shortHash(artifact.content_hash)}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="schedule-empty">
|
||||
暂无稳定版本,请先在"构建脚本"中发布
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { type ScheduleNode } from "../../services/api";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { shortHash } from "./utils";
|
||||
|
||||
type NodeForm = {
|
||||
nodeName: string;
|
||||
timeoutSeconds: string;
|
||||
retryCount: string;
|
||||
retryIntervalSec: string;
|
||||
argumentsJson: string;
|
||||
envRefsJson: string;
|
||||
};
|
||||
|
||||
export function NodeInspector({
|
||||
node,
|
||||
form,
|
||||
busy,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
node: ScheduleNode;
|
||||
form: NodeForm;
|
||||
busy: boolean;
|
||||
onChange: (form: NodeForm) => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="schedule-inspector">
|
||||
<section className="node-inspector-title">
|
||||
<span className={`artifact-card__icon artifact-card__icon--${node.version.script_type}`}>
|
||||
<Icon
|
||||
name={node.version.script_type === "notebook" ? "notebook" : "python"}
|
||||
size={17}
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<small>节点配置</small>
|
||||
<strong>{node.node_key}</strong>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>稳定版本</h3>
|
||||
<div className="version-readonly">
|
||||
<strong>{node.version.script_name}</strong>
|
||||
<span>{node.version.version_label}</span>
|
||||
<small>versions_id: {node.versions_id}</small>
|
||||
<small>SHA-256: {shortHash(node.version.content_hash)}</small>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>节点信息</h3>
|
||||
<label>
|
||||
<span>节点名称</span>
|
||||
<input
|
||||
value={form.nodeName}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
nodeName: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<div className="inspector-grid">
|
||||
<label>
|
||||
<span>超时(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.timeoutSeconds}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
timeoutSeconds: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>重试次数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={form.retryCount}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
retryCount: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>重试间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.retryIntervalSec}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
retryIntervalSec: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<section>
|
||||
<h3>运行参数</h3>
|
||||
<label>
|
||||
<span>arguments_json</span>
|
||||
<textarea
|
||||
className="json-editor"
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
value={form.argumentsJson}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
argumentsJson: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>env_refs_json</span>
|
||||
<textarea
|
||||
className="json-editor"
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
value={form.envRefsJson}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
envRefsJson: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<div className="node-inspector-actions">
|
||||
<button
|
||||
className="inspector-primary-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onSave}
|
||||
>
|
||||
保存节点
|
||||
</button>
|
||||
<button
|
||||
className="inspector-danger-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onDelete}
|
||||
>
|
||||
删除节点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type ScheduleRunSummary } from "../../services/api";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { formatTime, formatDuration } from "./utils";
|
||||
|
||||
const RUN_STATUS_LABELS: Record<string, string> = {
|
||||
queued: "排队中",
|
||||
running: "运行中",
|
||||
succeeded: "成功",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
timed_out: "已超时",
|
||||
};
|
||||
|
||||
export function RunHistory({
|
||||
runs,
|
||||
loading,
|
||||
disabled,
|
||||
onRefresh,
|
||||
}: {
|
||||
runs: ScheduleRunSummary[];
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="schedule-run-history">
|
||||
<header>
|
||||
<div>
|
||||
<strong>运行记录</strong>
|
||||
<span>{runs.length}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新运行记录"
|
||||
disabled={disabled || loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<Icon name="refresh" size={13} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="schedule-run-list">
|
||||
{loading && runs.length === 0 ? (
|
||||
<p>正在加载运行记录…</p>
|
||||
) : 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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Icon from "../../components/common/Icon";
|
||||
|
||||
export function ScheduleCanvasHeader({
|
||||
linkSourceId,
|
||||
onCancelLink,
|
||||
}: {
|
||||
linkSourceId: string | null;
|
||||
onCancelLink: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="schedule-canvas-title">
|
||||
<div>
|
||||
<strong>流程画布</strong>
|
||||
<span>
|
||||
拖入稳定版本;点击节点右侧圆点,再点击目标左侧圆点完成连线
|
||||
</span>
|
||||
</div>
|
||||
{linkSourceId && (
|
||||
<button type="button" onClick={onCancelLink}>
|
||||
取消连线
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { type Schedule, type CronPreview } from "../../services/api";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import { formatTime } from "./utils";
|
||||
|
||||
type ScheduleForm = {
|
||||
scheduleName: string;
|
||||
description: string;
|
||||
triggerType: "manual" | "cron" | "api";
|
||||
cronExpression: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
maxConcurrency: string;
|
||||
failurePolicy: "stop" | "continue";
|
||||
};
|
||||
|
||||
export function ScheduleInspector({
|
||||
schedule,
|
||||
form,
|
||||
cronResult,
|
||||
busy,
|
||||
onChange,
|
||||
onPreview,
|
||||
onSave,
|
||||
}: {
|
||||
schedule: Schedule | null;
|
||||
form: ScheduleForm;
|
||||
cronResult: CronPreview | null;
|
||||
busy: boolean;
|
||||
onChange: (form: ScheduleForm) => void;
|
||||
onPreview: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
if (!schedule) {
|
||||
return (
|
||||
<div className="schedule-inspector-empty">
|
||||
<Icon name="settings" size={28} />
|
||||
<strong>调度属性</strong>
|
||||
<p>选中调度方案后可配置触发方式、Cron 和执行策略。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="schedule-inspector">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<label>
|
||||
<span>调度名称</span>
|
||||
<input
|
||||
value={form.scheduleName}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
scheduleName: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>说明</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={form.description}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
description: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label className="schedule-switch-row">
|
||||
<span>
|
||||
<b>启用调度</b>
|
||||
<small>DAG 有效后才能启用</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
enabled: event.target.checked,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>触发器</h3>
|
||||
<label>
|
||||
<span>触发方式</span>
|
||||
<select
|
||||
value={form.triggerType}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
triggerType: event.target.value as typeof form.triggerType,
|
||||
enabled: event.target.value === "cron" ? form.enabled : false,
|
||||
})}
|
||||
>
|
||||
<option value="manual">手动触发</option>
|
||||
<option value="cron">Cron 定时</option>
|
||||
<option value="api">API 触发</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.triggerType === "cron" && (
|
||||
<>
|
||||
<label>
|
||||
<span>Cron(分 时 日 月 周)</span>
|
||||
<input
|
||||
value={form.cronExpression}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
cronExpression: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>时区</span>
|
||||
<input
|
||||
value={form.timezone}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
timezone: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="inspector-secondary-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onPreview}
|
||||
>
|
||||
预览未来 5 次
|
||||
</button>
|
||||
{cronResult && (
|
||||
<ol className="cron-preview-list">
|
||||
{cronResult.occurrences.map((item) => (
|
||||
<li key={item.utc_time}>
|
||||
{new Date(item.local_time).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>执行策略</h3>
|
||||
<div className="inspector-grid">
|
||||
<label>
|
||||
<span>最大并发</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
value={form.maxConcurrency}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
maxConcurrency: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>失败策略</span>
|
||||
<select
|
||||
value={form.failurePolicy}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
failurePolicy: event.target.value as "stop" | "continue",
|
||||
})}
|
||||
>
|
||||
<option value="stop">停止后续</option>
|
||||
<option value="continue">继续执行</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="schedule-next-run">
|
||||
<span>下一次执行</span>
|
||||
<strong>{formatTime(schedule.next_run_at)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="inspector-primary-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onSave}
|
||||
>
|
||||
保存调度属性
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type Schedule } from "../../services/api";
|
||||
import Icon from "../../components/common/Icon";
|
||||
|
||||
export function ScheduleList({
|
||||
schedules,
|
||||
selectedScheduleId,
|
||||
keyword,
|
||||
loading,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
onKeywordChange,
|
||||
}: {
|
||||
schedules: Schedule[];
|
||||
selectedScheduleId: string | null;
|
||||
keyword: string;
|
||||
loading: boolean;
|
||||
onSelect: (scheduleId: string) => void;
|
||||
onContextMenu: (event: React.MouseEvent, schedule: Schedule | null) => void;
|
||||
onKeywordChange: (keyword: string) => void;
|
||||
}) {
|
||||
const filtered = keyword.trim()
|
||||
? schedules.filter((item) => item.schedule_name.toLowerCase().includes(keyword.trim().toLowerCase()))
|
||||
: schedules;
|
||||
|
||||
return (
|
||||
<section className="schedule-panel schedule-list-panel">
|
||||
<div
|
||||
className="schedule-panel__title"
|
||||
onContextMenu={(event) => onContextMenu(event, null)}
|
||||
>
|
||||
<div><strong>调度方案</strong><span>{schedules.length}</span></div>
|
||||
</div>
|
||||
<label className="schedule-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
value={keyword}
|
||||
placeholder="搜索调度名称"
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className="schedule-list"
|
||||
onContextMenu={(event) => onContextMenu(event, null)}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="schedule-empty">正在加载调度方案…</p>
|
||||
) : filtered.length ? (
|
||||
filtered.map((item) => (
|
||||
<button
|
||||
className={`schedule-card${
|
||||
selectedScheduleId === item.schedule_id ? " schedule-card--active" : ""
|
||||
}`}
|
||||
key={item.schedule_id}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.schedule_id)}
|
||||
onContextMenu={(event) => onContextMenu(event, item)}
|
||||
>
|
||||
<span className={`schedule-status${item.enabled ? " is-enabled" : ""}`} />
|
||||
<span>
|
||||
<strong>{item.schedule_name}</strong>
|
||||
<small>
|
||||
{item.node_count} 个节点 · {item.enabled ? "已启用" : "未启用"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="schedule-empty">暂无调度方案</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -21,9 +21,33 @@ import {
|
||||
|
||||
import { useApi } from "../../context/AuthContext";
|
||||
import Icon from "../../components/common/Icon";
|
||||
import {
|
||||
CANVAS_WIDTH,
|
||||
CANVAS_HEIGHT,
|
||||
NODE_WIDTH,
|
||||
NODE_HEIGHT,
|
||||
ARTIFACT_MIME,
|
||||
EMPTY_SCHEDULE_FORM,
|
||||
EMPTY_NODE_FORM,
|
||||
} from "./constants";
|
||||
import {
|
||||
formatTime,
|
||||
shortHash,
|
||||
parseObject,
|
||||
scheduleToForm,
|
||||
nodeToForm,
|
||||
artifactNodeKey,
|
||||
edgePath,
|
||||
withSuppressedError,
|
||||
} from "./utils";
|
||||
import { ScheduleList } from "./ScheduleList";
|
||||
import { ArtifactList } from "./ArtifactList";
|
||||
import { ScheduleCanvasHeader } from "./ScheduleCanvasHeader";
|
||||
import { ScheduleInspector } from "./ScheduleInspector";
|
||||
import { NodeInspector } from "./NodeInspector";
|
||||
import { RunHistory } from "./RunHistory";
|
||||
import "../../styles/schedule.css";
|
||||
|
||||
|
||||
type Notice = {
|
||||
tone: "success" | "error" | "info";
|
||||
message: string;
|
||||
@@ -78,132 +102,6 @@ type ScheduleContextMenuTarget =
|
||||
| { kind: "node"; node: ScheduleNode }
|
||||
| { kind: "edge"; edge: ScheduleEdge };
|
||||
|
||||
const EMPTY_SCHEDULE_FORM: ScheduleForm = {
|
||||
scheduleName: "",
|
||||
description: "",
|
||||
triggerType: "manual",
|
||||
cronExpression: "0 9 * * *",
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: false,
|
||||
maxConcurrency: "1",
|
||||
failurePolicy: "stop",
|
||||
};
|
||||
|
||||
const EMPTY_NODE_FORM: NodeForm = {
|
||||
nodeName: "",
|
||||
timeoutSeconds: "600",
|
||||
retryCount: "0",
|
||||
retryIntervalSec: "5",
|
||||
argumentsJson: "{}",
|
||||
envRefsJson: "{}",
|
||||
};
|
||||
|
||||
const CANVAS_WIDTH = 1400;
|
||||
const CANVAS_HEIGHT = 860;
|
||||
const NODE_WIDTH = 218;
|
||||
const NODE_HEIGHT = 104;
|
||||
const ARTIFACT_MIME = "application/x-model-platform-version";
|
||||
|
||||
|
||||
function formatTime(value: string | null): string {
|
||||
if (!value) return "尚未执行";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return value ? `${value.slice(0, 7)}…${value.slice(-5)}` : "—";
|
||||
}
|
||||
|
||||
const RUN_STATUS_LABELS: Record<ScheduleRunSummary["run_status"], string> = {
|
||||
queued: "排队中",
|
||||
running: "运行中",
|
||||
succeeded: "成功",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
timed_out: "已超时",
|
||||
};
|
||||
|
||||
function formatDuration(value: number | null): string {
|
||||
if (value === null) return "—";
|
||||
if (value < 1000) return `${value} ms`;
|
||||
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
|
||||
return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
||||
function parseObject(text: string, label: string): Record<string, unknown> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`${label}必须是合法 JSON`);
|
||||
}
|
||||
if (!value || Array.isArray(value) || typeof value !== "object") {
|
||||
throw new Error(`${label}必须是 JSON 对象`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function scheduleToForm(schedule: Schedule): ScheduleForm {
|
||||
return {
|
||||
scheduleName: schedule.schedule_name,
|
||||
description: schedule.description ?? "",
|
||||
triggerType: schedule.trigger_type,
|
||||
cronExpression: schedule.cron_expression ?? "0 9 * * *",
|
||||
timezone: schedule.timezone,
|
||||
enabled: schedule.enabled,
|
||||
maxConcurrency: String(schedule.max_concurrency),
|
||||
failurePolicy: schedule.failure_policy,
|
||||
};
|
||||
}
|
||||
|
||||
function nodeToForm(node: ScheduleNode): NodeForm {
|
||||
return {
|
||||
nodeName: node.node_name,
|
||||
timeoutSeconds: String(node.timeout_seconds),
|
||||
retryCount: String(node.retry_count),
|
||||
retryIntervalSec: String(node.retry_interval_sec),
|
||||
argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
|
||||
envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function artifactNodeKey(
|
||||
artifact: ScheduleArtifact,
|
||||
schedule: Schedule,
|
||||
): string {
|
||||
const ascii = artifact.script_name
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/[^A-Za-z0-9_-]+/g, "_")
|
||||
.replace(/^([^A-Za-z])/, "n_$1")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 48);
|
||||
const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
|
||||
const existing = new Set(schedule.nodes.map((item) => item.node_key));
|
||||
if (!existing.has(base)) return base;
|
||||
let index = 2;
|
||||
while (existing.has(`${base}_${index}`)) index += 1;
|
||||
return `${base}_${index}`.slice(0, 64);
|
||||
}
|
||||
|
||||
function edgePath(
|
||||
source: ScheduleNode,
|
||||
target: ScheduleNode,
|
||||
): string {
|
||||
const x1 = source.position_x + NODE_WIDTH;
|
||||
const y1 = source.position_y + NODE_HEIGHT / 2;
|
||||
const x2 = target.position_x;
|
||||
const y2 = target.position_y + NODE_HEIGHT / 2;
|
||||
const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
|
||||
return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
|
||||
export default function SchedulePage({
|
||||
onNotify,
|
||||
onConnectionChange,
|
||||
@@ -224,10 +122,8 @@ export default function SchedulePage({
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [newScheduleName, setNewScheduleName] = useState("");
|
||||
const [contextMenu, setContextMenu] =
|
||||
useState<ScheduleContextMenu | null>(null);
|
||||
const [scheduleForm, setScheduleForm] =
|
||||
useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
||||
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
|
||||
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
||||
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
|
||||
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
|
||||
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
|
||||
@@ -411,18 +307,14 @@ export default function SchedulePage({
|
||||
};
|
||||
}, [schedule?.schedule_id]);
|
||||
|
||||
const hasActiveRuns = runs.some(
|
||||
(item) => item.run_status === "queued" || item.run_status === "running",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const scheduleId = schedule?.schedule_id;
|
||||
if (!scheduleId || !hasActiveRuns) return;
|
||||
if (!scheduleId || !runs.some((item) => item.run_status === "queued" || item.run_status === "running")) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshRuns(scheduleId);
|
||||
}, 1500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [schedule?.schedule_id, hasActiveRuns]);
|
||||
}, [schedule?.schedule_id, runs]);
|
||||
|
||||
const handleError = async (
|
||||
error: unknown,
|
||||
@@ -541,7 +433,7 @@ export default function SchedulePage({
|
||||
const selectedSchedule = target ?? schedule;
|
||||
if (!selectedSchedule || busy) return;
|
||||
setContextMenu(null);
|
||||
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
|
||||
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
|
||||
setBusy("delete-schedule");
|
||||
try {
|
||||
await api.deleteSchedule(
|
||||
@@ -601,7 +493,7 @@ export default function SchedulePage({
|
||||
setContextMenu(null);
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定将“${artifact.script_name} ${artifact.version_label}”移出调度列表吗?\n`
|
||||
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
|
||||
+ "稳定版本本身和历史运行记录不会被删除。",
|
||||
)
|
||||
) return;
|
||||
@@ -701,7 +593,7 @@ export default function SchedulePage({
|
||||
if (positionDraftCount > 0) {
|
||||
onNotify({
|
||||
tone: "info",
|
||||
message: "还有节点位置未保存,请先点击“保存配置”再运行",
|
||||
message: "还有节点位置未保存,请先点击'保存配置'再运行",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -771,15 +663,6 @@ export default function SchedulePage({
|
||||
}
|
||||
};
|
||||
|
||||
const onArtifactDragStart = (
|
||||
event: DragEvent<HTMLDivElement>,
|
||||
artifact: ScheduleArtifact,
|
||||
): void => {
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData(ARTIFACT_MIME, artifact.versions_id);
|
||||
event.dataTransfer.setData("text/plain", artifact.versions_id);
|
||||
};
|
||||
|
||||
const onCanvasDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||||
event.preventDefault();
|
||||
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|
||||
@@ -942,7 +825,7 @@ export default function SchedulePage({
|
||||
const node = target ?? selectedNode;
|
||||
if (!schedule || !node || busy) return;
|
||||
setContextMenu(null);
|
||||
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
|
||||
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
|
||||
const updated = await withMutation(
|
||||
"delete-node",
|
||||
() => api.deleteScheduleNode(
|
||||
@@ -1074,124 +957,30 @@ export default function SchedulePage({
|
||||
|
||||
<div className="schedule-workbench">
|
||||
<aside className="schedule-left">
|
||||
<section className="schedule-panel schedule-list-panel">
|
||||
<div
|
||||
className="schedule-panel__title"
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
|
||||
>
|
||||
<div><strong>调度方案</strong><span>{schedules.length}</span></div>
|
||||
</div>
|
||||
<label className="schedule-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
value={scheduleKeyword}
|
||||
placeholder="搜索调度名称"
|
||||
onChange={(event) => setScheduleKeyword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
className="schedule-list"
|
||||
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
|
||||
>
|
||||
{loading ? (
|
||||
<p className="schedule-empty">正在加载调度方案…</p>
|
||||
) : filteredSchedules.length ? (
|
||||
filteredSchedules.map((item) => (
|
||||
<button
|
||||
className={`schedule-card${
|
||||
schedule?.schedule_id === item.schedule_id
|
||||
? " schedule-card--active"
|
||||
: ""
|
||||
}`}
|
||||
key={item.schedule_id}
|
||||
type="button"
|
||||
onClick={() => void chooseSchedule(item.schedule_id)}
|
||||
onContextMenu={(event) => openContextMenu(event, {
|
||||
kind: "schedule",
|
||||
schedule: item,
|
||||
})}
|
||||
>
|
||||
<span className={`schedule-status${item.enabled ? " is-enabled" : ""}`} />
|
||||
<span>
|
||||
<strong>{item.schedule_name}</strong>
|
||||
<small>
|
||||
{item.node_count} 个节点 · {item.enabled ? "已启用" : "未启用"}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="schedule-empty">暂无调度方案</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="schedule-panel schedule-artifacts-panel">
|
||||
<div className="schedule-panel__title">
|
||||
<div><strong>稳定版本脚本</strong><span>{artifacts.length}</span></div>
|
||||
</div>
|
||||
<label className="schedule-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input
|
||||
value={artifactKeyword}
|
||||
placeholder="搜索稳定版本"
|
||||
onChange={(event) => setArtifactKeyword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="artifact-tip">拖动卡片到画布,节点将保存 versions_id</p>
|
||||
<div className="artifact-list">
|
||||
{filteredArtifacts.length ? filteredArtifacts.map((artifact) => (
|
||||
<div
|
||||
className="artifact-card"
|
||||
draggable
|
||||
key={artifact.versions_id}
|
||||
onDragStart={(event) => onArtifactDragStart(event, artifact)}
|
||||
onContextMenu={(event) => openContextMenu(event, {
|
||||
kind: "artifact",
|
||||
artifact,
|
||||
})}
|
||||
onDoubleClick={() => void addArtifactAt(
|
||||
artifact,
|
||||
90 + (schedule?.nodes.length ?? 0) * 245,
|
||||
130,
|
||||
)}
|
||||
>
|
||||
<span className={`artifact-card__icon artifact-card__icon--${artifact.script_type}`}>
|
||||
<Icon
|
||||
name={artifact.script_type === "notebook" ? "notebook" : "python"}
|
||||
size={17}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{artifact.script_name}</strong>
|
||||
<small>
|
||||
{artifact.version_label} · {shortHash(artifact.content_hash)}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="schedule-empty">
|
||||
暂无稳定版本,请先在“构建脚本”中发布
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<ScheduleList
|
||||
schedules={filteredSchedules}
|
||||
selectedScheduleId={schedule?.schedule_id ?? null}
|
||||
keyword={scheduleKeyword}
|
||||
loading={loading}
|
||||
onSelect={chooseSchedule}
|
||||
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
|
||||
onKeywordChange={setScheduleKeyword}
|
||||
/>
|
||||
<ArtifactList
|
||||
artifacts={filteredArtifacts}
|
||||
keyword={artifactKeyword}
|
||||
onDrop={(artifact, x, y) => void addArtifactAt(artifact, x, y)}
|
||||
onDoubleClick={(artifact) => void addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
|
||||
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
|
||||
onKeywordChange={setArtifactKeyword}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className="schedule-center">
|
||||
<div className="schedule-canvas-title">
|
||||
<div>
|
||||
<strong>流程画布</strong>
|
||||
<span>
|
||||
拖入稳定版本;点击节点右侧圆点,再点击目标左侧圆点完成连线
|
||||
</span>
|
||||
</div>
|
||||
{linkSourceId && (
|
||||
<button type="button" onClick={() => setLinkSourceId(null)}>
|
||||
取消连线
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ScheduleCanvasHeader
|
||||
linkSourceId={linkSourceId}
|
||||
onCancelLink={() => setLinkSourceId(null)}
|
||||
/>
|
||||
<div
|
||||
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
|
||||
ref={canvasRef}
|
||||
@@ -1346,7 +1135,7 @@ export default function SchedulePage({
|
||||
<div className="schedule-canvas-empty">
|
||||
<Icon name="schedule" size={34} />
|
||||
<strong>还没有选中调度方案</strong>
|
||||
<p>点击“新建”创建一个调度,然后拖入稳定版本脚本。</p>
|
||||
<p>点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
|
||||
<button type="button" onClick={openCreateScheduleDialog}>
|
||||
<Icon name="plus" size={15} />新建调度
|
||||
</button>
|
||||
@@ -1558,381 +1347,3 @@ export default function SchedulePage({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function RunHistory({
|
||||
runs,
|
||||
loading,
|
||||
disabled,
|
||||
onRefresh,
|
||||
}: {
|
||||
runs: ScheduleRunSummary[];
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="schedule-run-history">
|
||||
<header>
|
||||
<div>
|
||||
<strong>运行记录</strong>
|
||||
<span>{runs.length}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新运行记录"
|
||||
disabled={disabled || loading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<Icon name="refresh" size={13} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="schedule-run-list">
|
||||
{loading && runs.length === 0 ? (
|
||||
<p>正在加载运行记录…</p>
|
||||
) : 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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ScheduleInspector({
|
||||
schedule,
|
||||
form,
|
||||
cronResult,
|
||||
busy,
|
||||
onChange,
|
||||
onPreview,
|
||||
onSave,
|
||||
}: {
|
||||
schedule: Schedule | null;
|
||||
form: ScheduleForm;
|
||||
cronResult: CronPreview | null;
|
||||
busy: boolean;
|
||||
onChange: (form: ScheduleForm) => void;
|
||||
onPreview: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
if (!schedule) {
|
||||
return (
|
||||
<div className="schedule-inspector-empty">
|
||||
<Icon name="settings" size={28} />
|
||||
<strong>调度属性</strong>
|
||||
<p>选中调度方案后可配置触发方式、Cron 和执行策略。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="schedule-inspector">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<label>
|
||||
<span>调度名称</span>
|
||||
<input
|
||||
value={form.scheduleName}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
scheduleName: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>说明</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={form.description}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
description: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label className="schedule-switch-row">
|
||||
<span>
|
||||
<b>启用调度</b>
|
||||
<small>DAG 有效后才能启用</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
enabled: event.target.checked,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>触发器</h3>
|
||||
<label>
|
||||
<span>触发方式</span>
|
||||
<select
|
||||
value={form.triggerType}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
triggerType: event.target.value as ScheduleForm["triggerType"],
|
||||
enabled: event.target.value === "cron" ? form.enabled : false,
|
||||
})}
|
||||
>
|
||||
<option value="manual">手动触发</option>
|
||||
<option value="cron">Cron 定时</option>
|
||||
<option value="api">API 触发</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.triggerType === "cron" && (
|
||||
<>
|
||||
<label>
|
||||
<span>Cron(分 时 日 月 周)</span>
|
||||
<input
|
||||
value={form.cronExpression}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
cronExpression: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>时区</span>
|
||||
<input
|
||||
value={form.timezone}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
timezone: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="inspector-secondary-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onPreview}
|
||||
>
|
||||
预览未来 5 次
|
||||
</button>
|
||||
{cronResult && (
|
||||
<ol className="cron-preview-list">
|
||||
{cronResult.occurrences.map((item) => (
|
||||
<li key={item.utc_time}>
|
||||
{new Date(item.local_time).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>执行策略</h3>
|
||||
<div className="inspector-grid">
|
||||
<label>
|
||||
<span>最大并发</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="64"
|
||||
value={form.maxConcurrency}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
maxConcurrency: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>失败策略</span>
|
||||
<select
|
||||
value={form.failurePolicy}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
failurePolicy: event.target.value as "stop" | "continue",
|
||||
})}
|
||||
>
|
||||
<option value="stop">停止后续</option>
|
||||
<option value="continue">继续执行</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="schedule-next-run">
|
||||
<span>下一次执行</span>
|
||||
<strong>{formatTime(schedule.next_run_at)}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
className="inspector-primary-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onSave}
|
||||
>
|
||||
保存调度属性
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function NodeInspector({
|
||||
node,
|
||||
form,
|
||||
busy,
|
||||
onChange,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
node: ScheduleNode;
|
||||
form: NodeForm;
|
||||
busy: boolean;
|
||||
onChange: (form: NodeForm) => void;
|
||||
onSave: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="schedule-inspector">
|
||||
<section className="node-inspector-title">
|
||||
<span className={`artifact-card__icon artifact-card__icon--${node.version.script_type}`}>
|
||||
<Icon
|
||||
name={node.version.script_type === "notebook" ? "notebook" : "python"}
|
||||
size={17}
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<small>节点配置</small>
|
||||
<strong>{node.node_key}</strong>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>稳定版本</h3>
|
||||
<div className="version-readonly">
|
||||
<strong>{node.version.script_name}</strong>
|
||||
<span>{node.version.version_label}</span>
|
||||
<small>versions_id: {node.versions_id}</small>
|
||||
<small>SHA-256: {shortHash(node.version.content_hash)}</small>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>节点信息</h3>
|
||||
<label>
|
||||
<span>节点名称</span>
|
||||
<input
|
||||
value={form.nodeName}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
nodeName: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<div className="inspector-grid">
|
||||
<label>
|
||||
<span>超时(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.timeoutSeconds}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
timeoutSeconds: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>重试次数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={form.retryCount}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
retryCount: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>重试间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.retryIntervalSec}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
retryIntervalSec: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<section>
|
||||
<h3>运行参数</h3>
|
||||
<label>
|
||||
<span>arguments_json</span>
|
||||
<textarea
|
||||
className="json-editor"
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
value={form.argumentsJson}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
argumentsJson: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>env_refs_json</span>
|
||||
<textarea
|
||||
className="json-editor"
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
value={form.envRefsJson}
|
||||
onChange={(event) => onChange({
|
||||
...form,
|
||||
envRefsJson: event.target.value,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<div className="node-inspector-actions">
|
||||
<button
|
||||
className="inspector-primary-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onSave}
|
||||
>
|
||||
保存节点
|
||||
</button>
|
||||
<button
|
||||
className="inspector-danger-button"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onDelete}
|
||||
>
|
||||
删除节点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function withSuppressedError(action: () => Promise<void>): void {
|
||||
void action().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 调度页面常量
|
||||
|
||||
export const CANVAS_WIDTH = 1400;
|
||||
export const CANVAS_HEIGHT = 860;
|
||||
export const NODE_WIDTH = 218;
|
||||
export const NODE_HEIGHT = 104;
|
||||
export const ARTIFACT_MIME = "application/x-model-platform-version";
|
||||
|
||||
export const EMPTY_SCHEDULE_FORM = {
|
||||
scheduleName: "",
|
||||
description: "",
|
||||
triggerType: "manual" as "manual" | "cron" | "api",
|
||||
cronExpression: "0 9 * * *",
|
||||
timezone: "Asia/Shanghai",
|
||||
enabled: false,
|
||||
maxConcurrency: "1",
|
||||
failurePolicy: "stop" as "stop" | "continue",
|
||||
};
|
||||
|
||||
export const EMPTY_NODE_FORM = {
|
||||
nodeName: "",
|
||||
timeoutSeconds: "600",
|
||||
retryCount: "0",
|
||||
retryIntervalSec: "5",
|
||||
argumentsJson: "{}",
|
||||
envRefsJson: "{}",
|
||||
};
|
||||
|
||||
export const RUN_STATUS_LABELS: Record<string, string> = {
|
||||
queued: "排队中",
|
||||
running: "运行中",
|
||||
succeeded: "成功",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
timed_out: "已超时",
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { type Schedule, type ScheduleArtifact, type ScheduleNode } from "../../services/api";
|
||||
import { EMPTY_SCHEDULE_FORM, EMPTY_NODE_FORM } from "./constants";
|
||||
|
||||
export function formatTime(value: string | null): string {
|
||||
if (!value) return "尚未执行";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function shortHash(value: string): string {
|
||||
return value ? `${value.slice(0, 7)}…${value.slice(-5)}` : "—";
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null): string {
|
||||
if (value === null) return "—";
|
||||
if (value < 1000) return `${value} ms`;
|
||||
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
|
||||
return `${Math.floor(value / 60_000)}m ${Math.round((value % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
||||
export function parseObject(text: string, label: string): Record<string, unknown> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`${label}必须是合法 JSON`);
|
||||
}
|
||||
if (!value || Array.isArray(value) || typeof value !== "object") {
|
||||
throw new Error(`${label}必须是 JSON 对象`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function scheduleToForm(schedule: Schedule) {
|
||||
return {
|
||||
scheduleName: schedule.schedule_name,
|
||||
description: schedule.description ?? "",
|
||||
triggerType: schedule.trigger_type,
|
||||
cronExpression: schedule.cron_expression ?? "0 9 * * *",
|
||||
timezone: schedule.timezone,
|
||||
enabled: schedule.enabled,
|
||||
maxConcurrency: String(schedule.max_concurrency),
|
||||
failurePolicy: schedule.failure_policy,
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeToForm(node: ScheduleNode) {
|
||||
return {
|
||||
nodeName: node.node_name,
|
||||
timeoutSeconds: String(node.timeout_seconds),
|
||||
retryCount: String(node.retry_count),
|
||||
retryIntervalSec: String(node.retry_interval_sec),
|
||||
argumentsJson: JSON.stringify(node.arguments_json ?? {}, null, 2),
|
||||
envRefsJson: JSON.stringify(node.env_refs_json ?? {}, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export function artifactNodeKey(
|
||||
artifact: ScheduleArtifact,
|
||||
schedule: Schedule,
|
||||
): string {
|
||||
const ascii = artifact.script_name
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/[^A-Za-z0-9_-]+/g, "_")
|
||||
.replace(/^([^A-Za-z])/, "n_$1")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 48);
|
||||
const base = ascii || `node_${artifact.versions_id.slice(-6).toLowerCase()}`;
|
||||
const existing = new Set(schedule.nodes.map((item) => item.node_key));
|
||||
if (!existing.has(base)) return base;
|
||||
let index = 2;
|
||||
while (existing.has(`${base}_${index}`)) index += 1;
|
||||
return `${base}_${index}`.slice(0, 64);
|
||||
}
|
||||
|
||||
export function edgePath(
|
||||
source: ScheduleNode,
|
||||
target: ScheduleNode,
|
||||
): string {
|
||||
const x1 = source.position_x + 218;
|
||||
const y1 = source.position_y + 52;
|
||||
const x2 = target.position_x;
|
||||
const y2 = target.position_y + 52;
|
||||
const curve = Math.max(70, Math.abs(x2 - x1) * 0.45);
|
||||
return `M ${x1} ${y1} C ${x1 + curve} ${y1}, ${x2 - curve} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
export function withSuppressedError(action: () => Promise<void>): void {
|
||||
void action().catch(() => undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user