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 { useApi } from "../../context/AuthContext";
|
||||||
import Icon from "../../components/common/Icon";
|
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";
|
import "../../styles/schedule.css";
|
||||||
|
|
||||||
|
|
||||||
type Notice = {
|
type Notice = {
|
||||||
tone: "success" | "error" | "info";
|
tone: "success" | "error" | "info";
|
||||||
message: string;
|
message: string;
|
||||||
@@ -78,132 +102,6 @@ type ScheduleContextMenuTarget =
|
|||||||
| { kind: "node"; node: ScheduleNode }
|
| { kind: "node"; node: ScheduleNode }
|
||||||
| { kind: "edge"; edge: ScheduleEdge };
|
| { 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({
|
export default function SchedulePage({
|
||||||
onNotify,
|
onNotify,
|
||||||
onConnectionChange,
|
onConnectionChange,
|
||||||
@@ -224,10 +122,8 @@ export default function SchedulePage({
|
|||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
const [newScheduleName, setNewScheduleName] = useState("");
|
const [newScheduleName, setNewScheduleName] = useState("");
|
||||||
const [contextMenu, setContextMenu] =
|
const [contextMenu, setContextMenu] = useState<ScheduleContextMenu | null>(null);
|
||||||
useState<ScheduleContextMenu | null>(null);
|
const [scheduleForm, setScheduleForm] = useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
||||||
const [scheduleForm, setScheduleForm] =
|
|
||||||
useState<ScheduleForm>(EMPTY_SCHEDULE_FORM);
|
|
||||||
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
|
const [nodeForm, setNodeForm] = useState<NodeForm>(EMPTY_NODE_FORM);
|
||||||
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
|
const [cronResult, setCronResult] = useState<CronPreview | null>(null);
|
||||||
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
|
const [runs, setRuns] = useState<ScheduleRunSummary[]>([]);
|
||||||
@@ -411,18 +307,14 @@ export default function SchedulePage({
|
|||||||
};
|
};
|
||||||
}, [schedule?.schedule_id]);
|
}, [schedule?.schedule_id]);
|
||||||
|
|
||||||
const hasActiveRuns = runs.some(
|
|
||||||
(item) => item.run_status === "queued" || item.run_status === "running",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scheduleId = schedule?.schedule_id;
|
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(() => {
|
const timer = window.setInterval(() => {
|
||||||
void refreshRuns(scheduleId);
|
void refreshRuns(scheduleId);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [schedule?.schedule_id, hasActiveRuns]);
|
}, [schedule?.schedule_id, runs]);
|
||||||
|
|
||||||
const handleError = async (
|
const handleError = async (
|
||||||
error: unknown,
|
error: unknown,
|
||||||
@@ -541,7 +433,7 @@ export default function SchedulePage({
|
|||||||
const selectedSchedule = target ?? schedule;
|
const selectedSchedule = target ?? schedule;
|
||||||
if (!selectedSchedule || busy) return;
|
if (!selectedSchedule || busy) return;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
|
if (!window.confirm(`确定删除调度"${selectedSchedule.schedule_name}"吗?`)) return;
|
||||||
setBusy("delete-schedule");
|
setBusy("delete-schedule");
|
||||||
try {
|
try {
|
||||||
await api.deleteSchedule(
|
await api.deleteSchedule(
|
||||||
@@ -601,7 +493,7 @@ export default function SchedulePage({
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`确定将“${artifact.script_name} ${artifact.version_label}”移出调度列表吗?\n`
|
`确定将"${artifact.script_name} ${artifact.version_label}"移出调度列表吗?\n`
|
||||||
+ "稳定版本本身和历史运行记录不会被删除。",
|
+ "稳定版本本身和历史运行记录不会被删除。",
|
||||||
)
|
)
|
||||||
) return;
|
) return;
|
||||||
@@ -701,7 +593,7 @@ export default function SchedulePage({
|
|||||||
if (positionDraftCount > 0) {
|
if (positionDraftCount > 0) {
|
||||||
onNotify({
|
onNotify({
|
||||||
tone: "info",
|
tone: "info",
|
||||||
message: "还有节点位置未保存,请先点击“保存配置”再运行",
|
message: "还有节点位置未保存,请先点击'保存配置'再运行",
|
||||||
});
|
});
|
||||||
return;
|
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 => {
|
const onCanvasDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|
const versionsId = event.dataTransfer.getData(ARTIFACT_MIME)
|
||||||
@@ -942,7 +825,7 @@ export default function SchedulePage({
|
|||||||
const node = target ?? selectedNode;
|
const node = target ?? selectedNode;
|
||||||
if (!schedule || !node || busy) return;
|
if (!schedule || !node || busy) return;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
|
if (!window.confirm(`确定删除节点"${node.node_name}"吗?`)) return;
|
||||||
const updated = await withMutation(
|
const updated = await withMutation(
|
||||||
"delete-node",
|
"delete-node",
|
||||||
() => api.deleteScheduleNode(
|
() => api.deleteScheduleNode(
|
||||||
@@ -1074,124 +957,30 @@ export default function SchedulePage({
|
|||||||
|
|
||||||
<div className="schedule-workbench">
|
<div className="schedule-workbench">
|
||||||
<aside className="schedule-left">
|
<aside className="schedule-left">
|
||||||
<section className="schedule-panel schedule-list-panel">
|
<ScheduleList
|
||||||
<div
|
schedules={filteredSchedules}
|
||||||
className="schedule-panel__title"
|
selectedScheduleId={schedule?.schedule_id ?? null}
|
||||||
onContextMenu={(event) => openContextMenu(event, { kind: "schedule-list" })}
|
keyword={scheduleKeyword}
|
||||||
>
|
loading={loading}
|
||||||
<div><strong>调度方案</strong><span>{schedules.length}</span></div>
|
onSelect={chooseSchedule}
|
||||||
</div>
|
onContextMenu={(event, item) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, item ? { kind: "schedule", schedule: item } : { kind: "schedule-list" })}
|
||||||
<label className="schedule-search">
|
onKeywordChange={setScheduleKeyword}
|
||||||
<Icon name="search" size={15} />
|
/>
|
||||||
<input
|
<ArtifactList
|
||||||
value={scheduleKeyword}
|
artifacts={filteredArtifacts}
|
||||||
placeholder="搜索调度名称"
|
keyword={artifactKeyword}
|
||||||
onChange={(event) => setScheduleKeyword(event.target.value)}
|
onDrop={(artifact, x, y) => void addArtifactAt(artifact, x, y)}
|
||||||
/>
|
onDoubleClick={(artifact) => void addArtifactAt(artifact, 90 + (schedule?.nodes.length ?? 0) * 245, 130)}
|
||||||
</label>
|
onContextMenu={(event, artifact) => openContextMenu(event as ReactMouseEvent<HTMLElement | SVGElement>, { kind: "artifact", artifact })}
|
||||||
<div
|
onKeywordChange={setArtifactKeyword}
|
||||||
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>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="schedule-center">
|
<main className="schedule-center">
|
||||||
<div className="schedule-canvas-title">
|
<ScheduleCanvasHeader
|
||||||
<div>
|
linkSourceId={linkSourceId}
|
||||||
<strong>流程画布</strong>
|
onCancelLink={() => setLinkSourceId(null)}
|
||||||
<span>
|
/>
|
||||||
拖入稳定版本;点击节点右侧圆点,再点击目标左侧圆点完成连线
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{linkSourceId && (
|
|
||||||
<button type="button" onClick={() => setLinkSourceId(null)}>
|
|
||||||
取消连线
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
|
className={`schedule-canvas${linkSourceId ? " is-linking" : ""}`}
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
@@ -1346,7 +1135,7 @@ export default function SchedulePage({
|
|||||||
<div className="schedule-canvas-empty">
|
<div className="schedule-canvas-empty">
|
||||||
<Icon name="schedule" size={34} />
|
<Icon name="schedule" size={34} />
|
||||||
<strong>还没有选中调度方案</strong>
|
<strong>还没有选中调度方案</strong>
|
||||||
<p>点击“新建”创建一个调度,然后拖入稳定版本脚本。</p>
|
<p>点击"新建"创建一个调度,然后拖入稳定版本脚本。</p>
|
||||||
<button type="button" onClick={openCreateScheduleDialog}>
|
<button type="button" onClick={openCreateScheduleDialog}>
|
||||||
<Icon name="plus" size={15} />新建调度
|
<Icon name="plus" size={15} />新建调度
|
||||||
</button>
|
</button>
|
||||||
@@ -1558,381 +1347,3 @@ export default function SchedulePage({
|
|||||||
</section>
|
</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