重构模型平台前后端并移除Redis依赖
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.react-router
|
||||
build
|
||||
node_modules
|
||||
README.md
|
||||
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
.env
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
@@ -0,0 +1,22 @@
|
||||
# Frontend
|
||||
|
||||
React Router SPA,已将原前端按功能拆分到 `app/`:
|
||||
|
||||
```text
|
||||
app/components/ 通用图标组件
|
||||
app/features/platform/ 主框架、脚本工作区和 Jupyter 编辑
|
||||
app/features/schedules/ DAG 调度页面
|
||||
app/features/admin/ 工作台与系统管理
|
||||
app/routes/ React Router 路由入口
|
||||
app/services/ API 请求、DTO 和演示上下文
|
||||
app/styles/ 分功能样式
|
||||
```
|
||||
|
||||
本地开发:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
生产构建由根目录 `nginx/Dockerfile` 完成,构建结果复制到 Nginx 静态目录。
|
||||
@@ -0,0 +1,11 @@
|
||||
:root {
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
color: #1f354b;
|
||||
background: #f3f6f9;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-width: 1024px; min-height: 100%; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
@@ -0,0 +1,183 @@
|
||||
type IconName =
|
||||
| "brand"
|
||||
| "home"
|
||||
| "script"
|
||||
| "schedule"
|
||||
| "experiment"
|
||||
| "database"
|
||||
| "settings"
|
||||
| "chevron"
|
||||
| "search"
|
||||
| "plus"
|
||||
| "upload"
|
||||
| "folder"
|
||||
| "notebook"
|
||||
| "python"
|
||||
| "refresh"
|
||||
| "close"
|
||||
| "check"
|
||||
| "info"
|
||||
| "workspace"
|
||||
| "menu"
|
||||
| "external"
|
||||
| "release"
|
||||
| "play";
|
||||
|
||||
type IconProps = {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
};
|
||||
|
||||
export default function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
strokeWidth = 1.8,
|
||||
}: IconProps) {
|
||||
const common = {
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"aria-hidden": true,
|
||||
};
|
||||
|
||||
if (name === "brand") {
|
||||
return (
|
||||
<svg {...common} viewBox="0 0 32 32">
|
||||
<path
|
||||
d="M16 3.5 27 9.8v12.4L16 28.5 5 22.2V9.8L16 3.5Z"
|
||||
fill="currentColor"
|
||||
opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M16 7.7 23.4 12v8L16 24.3 8.6 20v-8L16 7.7Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="3.3" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const paths: Record<Exclude<IconName, "brand">, React.ReactNode> = {
|
||||
home: (
|
||||
<>
|
||||
<path d="m3.5 11 8.5-7 8.5 7" />
|
||||
<path d="M5.5 10v10h13V10M9.5 20v-6h5v6" />
|
||||
</>
|
||||
),
|
||||
script: (
|
||||
<>
|
||||
<rect x="5" y="3.5" width="14" height="17" rx="2" />
|
||||
<path d="M8.5 8h7M8.5 12h7M8.5 16h4.5" />
|
||||
</>
|
||||
),
|
||||
schedule: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5V12l3.2 2" />
|
||||
</>
|
||||
),
|
||||
experiment: (
|
||||
<>
|
||||
<path d="M9 3.5h6M10 3.5v5l-5.2 9a2 2 0 0 0 1.7 3h11a2 2 0 0 0 1.7-3L14 8.5v-5" />
|
||||
<path d="M7.3 15h9.4" />
|
||||
</>
|
||||
),
|
||||
database: (
|
||||
<>
|
||||
<ellipse cx="12" cy="5.5" rx="7.5" ry="3" />
|
||||
<path d="M4.5 5.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
|
||||
<path d="M4.5 11.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
chevron: <path d="m9 7 5 5-5 5" />,
|
||||
search: (
|
||||
<>
|
||||
<circle cx="10.5" cy="10.5" r="6.5" />
|
||||
<path d="m15.5 15.5 4 4" />
|
||||
</>
|
||||
),
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
upload: (
|
||||
<>
|
||||
<path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" />
|
||||
<path d="M4 15.5V20h16v-4.5" />
|
||||
</>
|
||||
),
|
||||
folder: (
|
||||
<path d="M3.5 6.5h6l2 2H20.5v10.5a1.5 1.5 0 0 1-1.5 1.5H5a1.5 1.5 0 0 1-1.5-1.5V6.5Z" />
|
||||
),
|
||||
notebook: (
|
||||
<>
|
||||
<rect x="5" y="3.5" width="14" height="17" rx="2" />
|
||||
<path d="M8.5 3.5v17M11.5 8h4M11.5 12h4M11.5 16h2.5" />
|
||||
</>
|
||||
),
|
||||
python: (
|
||||
<>
|
||||
<path d="M8 4.5h5.5a2.5 2.5 0 0 1 2.5 2.5v3H8a3 3 0 0 0-3 3v2" />
|
||||
<path d="M16 19.5h-5.5A2.5 2.5 0 0 1 8 17v-3h8a3 3 0 0 0 3-3V9" />
|
||||
<circle cx="11" cy="7" r=".8" fill="currentColor" stroke="none" />
|
||||
<circle cx="13" cy="17" r=".8" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
refresh: (
|
||||
<>
|
||||
<path d="M19 8V4l-1.7 1.7A8 8 0 1 0 20 12" />
|
||||
<path d="M19 4h-4" />
|
||||
</>
|
||||
),
|
||||
close: <path d="m6 6 12 12M18 6 6 18" />,
|
||||
check: <path d="m5 12.5 4 4L19 7" />,
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5M12 8h.01" />
|
||||
</>
|
||||
),
|
||||
workspace: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="2" />
|
||||
<path d="M8 5V3.5h8V5M8 11h8M12 8v6" />
|
||||
</>
|
||||
),
|
||||
menu: (
|
||||
<>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</>
|
||||
),
|
||||
external: (
|
||||
<>
|
||||
<path d="M14 5h5v5M19 5l-8 8" />
|
||||
<path d="M18 13v6H5V6h6" />
|
||||
</>
|
||||
),
|
||||
release: (
|
||||
<>
|
||||
<path d="M12 3.5v11M7.5 8 12 3.5 16.5 8" />
|
||||
<path d="M5 12.5v7h14v-7" />
|
||||
</>
|
||||
),
|
||||
play: <path d="m8 5 11 7-11 7V5Z" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...common}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
ApiRequestError,
|
||||
createEmployee,
|
||||
deleteEmployee,
|
||||
demoContext,
|
||||
listEmployees,
|
||||
updateEmployee,
|
||||
type Employee,
|
||||
} from "../../services/api";
|
||||
import Icon from "../../components/Icon";
|
||||
import "../../styles/admin.css";
|
||||
import "../../styles/dashboard.css";
|
||||
|
||||
|
||||
type Notice = {
|
||||
tone: "success" | "error" | "info";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function DashboardPage({
|
||||
scriptCount,
|
||||
online,
|
||||
onNavigate,
|
||||
}: {
|
||||
scriptCount: number;
|
||||
online: boolean;
|
||||
onNavigate: (page: "scripts" | "schedules" | "system") => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<div className="dashboard-hero">
|
||||
<div>
|
||||
<span>MODEL DEVELOPMENT PLATFORM</span>
|
||||
<h2>下午好,{demoContext.userName}</h2>
|
||||
<p>当前位于 {demoContext.workspaceName},可以继续构建脚本或配置调度。</p>
|
||||
</div>
|
||||
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
||||
</div>
|
||||
<div className="dashboard-metrics">
|
||||
<article><Icon name="script" /><span><b>{scriptCount}</b><small>工作副本</small></span></article>
|
||||
<article><Icon name="workspace" /><span><b>2</b><small>Workspace</small></span></article>
|
||||
<article><Icon name="settings" /><span><b>4</b><small>平台员工</small></span></article>
|
||||
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small>平台状态</small></span></article>
|
||||
</div>
|
||||
<div className="dashboard-actions">
|
||||
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" />构建脚本</button>
|
||||
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" />调度配置</button>
|
||||
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" />员工管理</button>
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<section className="dashboard-panel dashboard-panel--trend">
|
||||
<header><div><span>运行趋势</span><h3>近 7 天调度执行</h3></div><b>成功率 92.6%</b></header>
|
||||
<div className="trend-chart">
|
||||
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
|
||||
<div className="trend-column" key={index}>
|
||||
<span className="trend-column__value">{Math.round(value / 7)}</span>
|
||||
<i style={{ height: `${value}%` }} />
|
||||
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<footer><span><i className="legend-dot is-success" />成功 75</span><span><i className="legend-dot is-failed" />失败 6</span></footer>
|
||||
</section>
|
||||
<section className="dashboard-panel dashboard-panel--donut">
|
||||
<header><div><span>脚本资产</span><h3>类型分布</h3></div></header>
|
||||
<div className="donut-layout">
|
||||
<div className="donut-chart"><span><b>{scriptCount}</b><small>全部脚本</small></span></div>
|
||||
<div className="donut-legend">
|
||||
<span><i className="legend-dot is-notebook" /><b>Notebook</b><small>{Math.max(1, Math.round(scriptCount * .67))} 个 · 67%</small></span>
|
||||
<span><i className="legend-dot is-python" /><b>Python</b><small>{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33%</small></span>
|
||||
<span><i className="legend-dot is-version" /><b>稳定版本</b><small>3 个已发布</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="dashboard-panel dashboard-panel--activity">
|
||||
<header><div><span>ACTIVITY</span><h3>最近平台活动</h3></div><button type="button">查看全部</button></header>
|
||||
<div className="activity-table">
|
||||
<div className="activity-table__head"><span>操作内容</span><span>执行人</span><span>状态</span><span>时间</span></div>
|
||||
{[
|
||||
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
|
||||
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
|
||||
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
|
||||
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
|
||||
].map((row) => (
|
||||
<div className="activity-row" key={row[0]}>
|
||||
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span>
|
||||
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const EMPTY_FORM = {
|
||||
username: "",
|
||||
display_name: "",
|
||||
email: "",
|
||||
role_code: "developer" as "admin" | "developer",
|
||||
status: "active" as "active" | "disabled" | "locked",
|
||||
};
|
||||
|
||||
export function SystemAdminPage({
|
||||
onNotify,
|
||||
onConnectionChange,
|
||||
}: {
|
||||
onNotify: (notice: Notice) => void;
|
||||
onConnectionChange: (online: boolean) => void;
|
||||
}) {
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editing, setEditing] = useState<Employee | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const canManage = demoContext.roleCode === "admin";
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setEmployees(await listEmployees());
|
||||
onConnectionChange(true);
|
||||
} catch (error) {
|
||||
onConnectionChange(false);
|
||||
onNotify({
|
||||
tone: "error",
|
||||
message: error instanceof Error ? error.message : "员工列表加载失败",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (employee: Employee): void => {
|
||||
setEditing(employee);
|
||||
setForm({
|
||||
username: employee.username,
|
||||
display_name: employee.display_name,
|
||||
email: employee.email ?? "",
|
||||
role_code: employee.role_code,
|
||||
status: employee.status,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
if (!form.display_name.trim() || (!editing && !form.username.trim())) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await updateEmployee(editing.user_id, {
|
||||
display_name: form.display_name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
role_code: form.role_code,
|
||||
status: form.status,
|
||||
});
|
||||
setEmployees((current) => current.map(
|
||||
(item) => item.user_id === updated.user_id ? updated : item,
|
||||
));
|
||||
} else {
|
||||
const created = await createEmployee({
|
||||
username: form.username.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
role_code: form.role_code,
|
||||
});
|
||||
setEmployees((current) => [...current, created]);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
onNotify({ tone: "success", message: editing ? "员工信息已更新" : "员工已添加" });
|
||||
} catch (error) {
|
||||
onNotify({
|
||||
tone: "error",
|
||||
message: error instanceof ApiRequestError ? error.message : "保存员工失败",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (employee: Employee): Promise<void> => {
|
||||
if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
|
||||
try {
|
||||
await deleteEmployee(employee.user_id);
|
||||
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
|
||||
onNotify({ tone: "success", message: "员工已删除" });
|
||||
} catch (error) {
|
||||
onNotify({
|
||||
tone: "error",
|
||||
message: error instanceof Error ? error.message : "删除员工失败",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<header className="admin-page__header">
|
||||
<div><span>系统管理</span><h2>员工管理</h2><p>{demoContext.workspaceName} · {employees.length} 名员工</p></div>
|
||||
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
|
||||
<Icon name="plus" size={15} />添加员工
|
||||
</button>
|
||||
</header>
|
||||
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看员工列表。</div>}
|
||||
<div className="employee-table">
|
||||
<div className="employee-table__head"><span>员工</span><span>账号</span><span>角色</span><span>状态</span><span>操作</span></div>
|
||||
{loading ? <p className="admin-empty">正在加载员工…</p> : employees.map((employee) => {
|
||||
const isProtectedAdmin = employee.role_code === "admin";
|
||||
return (
|
||||
<div className="employee-row" key={employee.user_id}>
|
||||
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
|
||||
<code>{employee.username}</code>
|
||||
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
|
||||
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
|
||||
<span className="employee-actions">
|
||||
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}>编辑</button>
|
||||
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}>删除</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{dialogOpen && (
|
||||
<div className="modal-backdrop">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header"><div><span className="modal__eyebrow">EMPLOYEE</span><h2>{editing ? "编辑员工" : "添加员工"}</h2></div><button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button></div>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label className="form-field"><span>姓名</span><input autoFocus value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} /></label>
|
||||
<label className="form-field"><span>登录账号</span><input disabled={Boolean(editing)} value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></label>
|
||||
<label className="form-field"><span>邮箱</span><input type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></label>
|
||||
<label className="form-field"><span>角色</span><select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}><option value="developer">开发人员</option><option value="admin">管理员</option></select></label>
|
||||
{editing && <label className="form-field"><span>状态</span><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}><option value="active">正常</option><option value="disabled">停用</option><option value="locked">锁定</option></select></label>}
|
||||
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}>取消</button><button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "页面加载失败";
|
||||
let details = "系统发生了未预期的错误。";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "请求失败";
|
||||
details =
|
||||
error.status === 404
|
||||
? "没有找到请求的页面。"
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [route("*", "routes/platform.tsx")] satisfies RouteConfig;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Route } from "./+types/platform";
|
||||
import ModelPlatformApp from "../features/platform/ModelPlatformApp";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "模型实验开发平台" },
|
||||
{ name: "description", content: "模型实验、脚本版本与 DAG 调度平台" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function PlatformRoute() {
|
||||
return <ModelPlatformApp />;
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
export type DemoUser = {
|
||||
userId: string;
|
||||
userName: string;
|
||||
username: string;
|
||||
roleCode: "admin" | "developer";
|
||||
roleName: string;
|
||||
};
|
||||
|
||||
export type DemoWorkspace = {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
};
|
||||
|
||||
export const demoUsers: DemoUser[] = [
|
||||
{ userId: "0000000000RF6FG1SDBXG59S13", userName: "张三", username: "admin-zhang", roleCode: "admin", roleName: "管理员" },
|
||||
{ userId: "0000000000H2QYCGPCWQM1JSGS", userName: "李四", username: "admin-li", roleCode: "admin", roleName: "管理员" },
|
||||
{ userId: "0000000000RWG40ESZPGJT629J", userName: "王五", username: "dev-wang", roleCode: "developer", roleName: "开发人员" },
|
||||
{ userId: "00000000004CQV7WASJA6N6FW4", userName: "赵六", username: "dev-zhao", roleCode: "developer", roleName: "开发人员" },
|
||||
];
|
||||
|
||||
export const demoWorkspaces: DemoWorkspace[] = [
|
||||
{ workspaceId: "00000000000BM630VT9ARVFZPC", workspaceName: "模型开发 Workspace" },
|
||||
{ workspaceId: "0000000000AE0NC0V5T424KK86", workspaceName: "风险验证 Workspace" },
|
||||
];
|
||||
|
||||
function readStoredContext(): Partial<{
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
return JSON.parse(
|
||||
window.localStorage.getItem("model-platform-demo-context") ?? "{}",
|
||||
) as Partial<{ userId: string; workspaceId: string }>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const storedContext = readStoredContext();
|
||||
const initialUser = demoUsers.find((item) => item.userId === storedContext.userId)
|
||||
?? demoUsers[0];
|
||||
const initialWorkspace = demoWorkspaces.find(
|
||||
(item) => item.workspaceId === storedContext.workspaceId,
|
||||
) ?? demoWorkspaces[0];
|
||||
|
||||
export const demoContext = {
|
||||
...initialUser,
|
||||
...initialWorkspace,
|
||||
};
|
||||
|
||||
export function setDemoContext(input: {
|
||||
user?: DemoUser;
|
||||
workspace?: DemoWorkspace;
|
||||
}): void {
|
||||
if (input.user) Object.assign(demoContext, input.user);
|
||||
if (input.workspace) Object.assign(demoContext, input.workspace);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("model-platform-demo-context", JSON.stringify({
|
||||
userId: demoContext.userId,
|
||||
workspaceId: demoContext.workspaceId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export type ScriptType = "python" | "notebook";
|
||||
export type Visibility = "private" | "workspace" | "public";
|
||||
|
||||
export type Employee = {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
status: "active" | "disabled" | "locked";
|
||||
role_code: "admin" | "developer";
|
||||
role_name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ScriptItem = {
|
||||
script_id: string;
|
||||
workspace_id: string;
|
||||
current_object_id: string;
|
||||
owner_user_id: string;
|
||||
script_name: string;
|
||||
script_type: ScriptType;
|
||||
visibility: Visibility;
|
||||
status: string;
|
||||
relative_path: string;
|
||||
content_hash: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceDirectory = {
|
||||
path: string;
|
||||
name: string;
|
||||
parent_path: string;
|
||||
};
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
request_id: string;
|
||||
data: T;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ApiErrorEnvelope = {
|
||||
detail?: string | {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: {
|
||||
editor_name?: string;
|
||||
lease_expires_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-User-ID": demoContext.userId,
|
||||
"X-Workspace-ID": demoContext.workspaceId,
|
||||
"X-Request-ID": crypto.randomUUID().replaceAll("-", ""),
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as
|
||||
| ApiEnvelope<T>
|
||||
| ApiErrorEnvelope;
|
||||
if (!response.ok) {
|
||||
const error = payload as ApiErrorEnvelope;
|
||||
const detailMessage = typeof error.detail === "string"
|
||||
? error.detail
|
||||
: error.detail?.message;
|
||||
const editor = error.error?.details?.editor_name;
|
||||
throw new ApiRequestError(
|
||||
(editor ? `${error.error?.message ?? "文件正在编辑"}(${editor})` : undefined)
|
||||
?? error.error?.message
|
||||
?? detailMessage
|
||||
?? `请求失败(HTTP ${response.status})`,
|
||||
response.status,
|
||||
typeof error.detail === "object"
|
||||
? error.detail?.code
|
||||
: error.error?.code,
|
||||
);
|
||||
}
|
||||
return (payload as ApiEnvelope<T>).data;
|
||||
}
|
||||
|
||||
export async function listScripts(): Promise<ScriptItem[]> {
|
||||
return apiRequest<ScriptItem[]>("/api/v1/scripts");
|
||||
}
|
||||
|
||||
function initialContent(scriptType: ScriptType): string {
|
||||
if (scriptType === "python") {
|
||||
return [
|
||||
'"""模型实验开发平台构建脚本。"""',
|
||||
"",
|
||||
"",
|
||||
"def main() -> None:",
|
||||
' print("Hello, Model Platform!")',
|
||||
"",
|
||||
"",
|
||||
'if __name__ == "__main__":',
|
||||
" main()",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
cells: [
|
||||
{
|
||||
cell_type: "markdown",
|
||||
metadata: {},
|
||||
source: ["# 新建模型实验\\n", "在这里开始数据探索与模型构建。"],
|
||||
},
|
||||
{
|
||||
cell_type: "code",
|
||||
execution_count: null,
|
||||
metadata: {},
|
||||
outputs: [],
|
||||
source: ["print('Hello, Model Platform!')\\n"],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
kernelspec: {
|
||||
display_name: "Python 3",
|
||||
language: "python",
|
||||
name: "python3",
|
||||
},
|
||||
language_info: {
|
||||
name: "python",
|
||||
version: "3.12",
|
||||
},
|
||||
},
|
||||
nbformat: 4,
|
||||
nbformat_minor: 5,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScript(input: {
|
||||
name: string;
|
||||
scriptType: ScriptType;
|
||||
visibility: Visibility;
|
||||
parentPath?: string | null;
|
||||
}): Promise<ScriptItem> {
|
||||
return apiRequest<ScriptItem>("/api/v1/scripts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
script_name: input.name.trim(),
|
||||
script_type: input.scriptType,
|
||||
visibility: input.visibility,
|
||||
content: initialContent(input.scriptType),
|
||||
parent_path: input.parentPath,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadScript(
|
||||
file: File,
|
||||
parentPath = "",
|
||||
visibility: Visibility = "workspace",
|
||||
): Promise<ScriptItem> {
|
||||
const parameters = new URLSearchParams({
|
||||
file_name: file.name,
|
||||
parent_path: parentPath,
|
||||
visibility,
|
||||
});
|
||||
return apiRequest<ScriptItem>(
|
||||
`/api/v1/scripts/upload?${parameters.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: file,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScript(
|
||||
scriptId: string,
|
||||
): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
|
||||
return apiRequest(`/api/v1/scripts/${scriptId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function listWorkspaceDirectories(): Promise<
|
||||
WorkspaceDirectory[]
|
||||
> {
|
||||
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
|
||||
"/api/v1/workspace-tree",
|
||||
);
|
||||
return data.directories;
|
||||
}
|
||||
|
||||
export async function createWorkspaceDirectory(
|
||||
directoryName: string,
|
||||
parentPath = "",
|
||||
): Promise<WorkspaceDirectory> {
|
||||
return apiRequest<WorkspaceDirectory>("/api/v1/workspace-directories", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
directory_name: directoryName,
|
||||
parent_path: parentPath,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceDirectory(
|
||||
path: string,
|
||||
): Promise<{
|
||||
path: string;
|
||||
status: string;
|
||||
deleted_scripts: number;
|
||||
versions_preserved: boolean;
|
||||
}> {
|
||||
const parameters = new URLSearchParams({ path });
|
||||
return apiRequest(`/api/v1/workspace-directories?${parameters.toString()}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export type FileLockSession = {
|
||||
edit_session_id: string;
|
||||
workspace_id: string;
|
||||
storage_object_id: string;
|
||||
user_id: string;
|
||||
session_status: "active" | "closed" | "expired";
|
||||
lease_seconds: number;
|
||||
heartbeat_interval_seconds: number;
|
||||
expires_at: string;
|
||||
runtime_id: string;
|
||||
jupyter_session_id: string;
|
||||
jupyter_url?: string;
|
||||
relative_path?: string;
|
||||
lock_token?: string;
|
||||
};
|
||||
|
||||
export type ActiveEditSession = FileLockSession & {
|
||||
script_id: string;
|
||||
script_name: string;
|
||||
lock_token: string;
|
||||
ticket_expires_at?: string;
|
||||
};
|
||||
|
||||
export type JupyterAccessTicket = {
|
||||
edit_session_id: string;
|
||||
jupyter_url: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export type StableVersion = {
|
||||
versions_id: string;
|
||||
workspace_id: string;
|
||||
script_id: string;
|
||||
source_object_id: string;
|
||||
artifact_object_id: string;
|
||||
version_no: number;
|
||||
version_label: string;
|
||||
source_path: string;
|
||||
artifact_path: string;
|
||||
content_hash: string;
|
||||
file_size_bytes: number;
|
||||
visibility: Visibility;
|
||||
release_note: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function acquireFileLock(
|
||||
script: ScriptItem,
|
||||
): Promise<ActiveEditSession> {
|
||||
const session = await apiRequest<FileLockSession>(
|
||||
`/api/v1/files/${script.current_object_id}/lock`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!session.lock_token) {
|
||||
throw new Error("加锁成功响应缺少 lock_token");
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
script_id: script.script_id,
|
||||
script_name: script.script_name,
|
||||
lock_token: session.lock_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function heartbeatFileLock(
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return apiRequest<FileLockSession>(
|
||||
`/api/v1/file-locks/${session.edit_session_id}/heartbeat`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function releaseFileLock(
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return apiRequest<FileLockSession>(
|
||||
`/api/v1/file-locks/${session.edit_session_id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function releaseFileLockOnUnload(session: ActiveEditSession): void {
|
||||
void fetch(`/api/v1/file-locks/${session.edit_session_id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
keepalive: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-User-ID": demoContext.userId,
|
||||
"X-Workspace-ID": demoContext.workspaceId,
|
||||
"X-Request-ID": crypto.randomUUID().replaceAll("-", ""),
|
||||
},
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createJupyterAccessTicket(
|
||||
session: ActiveEditSession,
|
||||
): Promise<JupyterAccessTicket> {
|
||||
return apiRequest<JupyterAccessTicket>("/api/v1/jupyter/access-tickets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
edit_session_id: session.edit_session_id,
|
||||
lock_token: session.lock_token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listScriptVersions(
|
||||
scriptId: string,
|
||||
): Promise<StableVersion[]> {
|
||||
return apiRequest<StableVersion[]>(`/api/v1/scripts/${scriptId}/versions`);
|
||||
}
|
||||
|
||||
export async function publishScriptVersion(input: {
|
||||
script: ScriptItem;
|
||||
releaseNote: string;
|
||||
visibility: Visibility;
|
||||
}): Promise<StableVersion> {
|
||||
return apiRequest<StableVersion>(
|
||||
`/api/v1/scripts/${input.script.script_id}/versions`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
source_object_id: input.script.current_object_id,
|
||||
release_note: input.releaseNote.trim() || null,
|
||||
visibility: input.visibility,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type ScheduleArtifact = {
|
||||
versions_id: string;
|
||||
version_label: string;
|
||||
script_id: string;
|
||||
script_name: string;
|
||||
script_type: ScriptType;
|
||||
content_hash: string;
|
||||
file_size_bytes: number;
|
||||
visibility: Visibility;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ScheduleNode = {
|
||||
node_id: string;
|
||||
schedule_id: string;
|
||||
node_key: string;
|
||||
node_name: string;
|
||||
versions_id: string;
|
||||
timeout_seconds: number;
|
||||
retry_count: number;
|
||||
retry_interval_sec: number;
|
||||
position_x: number;
|
||||
position_y: number;
|
||||
arguments_json: Record<string, unknown>;
|
||||
env_refs_json: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: {
|
||||
versions_id: string;
|
||||
version_label: string;
|
||||
script_id: string;
|
||||
script_name: string;
|
||||
script_type: ScriptType;
|
||||
content_hash: string;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ScheduleEdge = {
|
||||
edge_id: string;
|
||||
schedule_id: string;
|
||||
source_node_id: string;
|
||||
target_node_id: string;
|
||||
condition_expr: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type DagValidation = {
|
||||
valid: boolean;
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
root_node_ids: string[];
|
||||
leaf_node_ids: string[];
|
||||
topological_order: string[];
|
||||
errors: Array<{
|
||||
code: string;
|
||||
message: string;
|
||||
edge_id?: string;
|
||||
node_ids?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type Schedule = {
|
||||
schedule_id: string;
|
||||
workspace_id: string;
|
||||
schedule_name: string;
|
||||
description: string | null;
|
||||
trigger_type: "manual" | "cron" | "api";
|
||||
cron_expression: string | null;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
workflow_version: number;
|
||||
max_concurrency: number;
|
||||
failure_policy: "stop" | "continue";
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
nodes: ScheduleNode[];
|
||||
edges: ScheduleEdge[];
|
||||
dag_validation: DagValidation;
|
||||
};
|
||||
|
||||
export type CronPreview = {
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
base_time: string;
|
||||
occurrences: Array<{
|
||||
local_time: string;
|
||||
utc_time: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ScheduleRunStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "timed_out";
|
||||
|
||||
export type ScheduleNodeRunStatus =
|
||||
| ScheduleRunStatus
|
||||
| "skipped";
|
||||
|
||||
export type ScheduleRunSummary = {
|
||||
run_id: string;
|
||||
schedule_id: string;
|
||||
workspace_id: string;
|
||||
workflow_version: number;
|
||||
trigger_type: "manual" | "cron" | "api" | "retry";
|
||||
run_status: ScheduleRunStatus;
|
||||
state_version: number;
|
||||
queued_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
logs_object_id: string | null;
|
||||
result_object_id: string | null;
|
||||
};
|
||||
|
||||
export type ScheduleNodeRun = {
|
||||
node_run_id: string;
|
||||
run_id: string;
|
||||
node_id: string;
|
||||
versions_id: string;
|
||||
attempt_no: number;
|
||||
node_status: ScheduleNodeRunStatus;
|
||||
state_version: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
exit_code: number | null;
|
||||
message: string | null;
|
||||
logs_object_id: string | null;
|
||||
result_object_id: string | null;
|
||||
};
|
||||
|
||||
export type ScheduleRunDetail = ScheduleRunSummary & {
|
||||
node_runs: ScheduleNodeRun[];
|
||||
};
|
||||
|
||||
export async function listSchedules(): Promise<Schedule[]> {
|
||||
return apiRequest<Schedule[]>("/api/v1/schedules");
|
||||
}
|
||||
|
||||
export async function getSchedule(scheduleId: string): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`);
|
||||
}
|
||||
|
||||
export async function createSchedule(input: {
|
||||
schedule_name: string;
|
||||
description?: string | null;
|
||||
trigger_type?: "manual" | "cron" | "api";
|
||||
cron_expression?: string | null;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
max_concurrency?: number;
|
||||
failure_policy?: "stop" | "continue";
|
||||
}): Promise<Schedule> {
|
||||
return apiRequest<Schedule>("/api/v1/schedules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSchedule(
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
schedule_name?: string;
|
||||
description?: string | null;
|
||||
trigger_type?: "manual" | "cron" | "api";
|
||||
cron_expression?: string | null;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
max_concurrency?: number;
|
||||
failure_policy?: "stop" | "continue";
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSchedule(
|
||||
scheduleId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> {
|
||||
return apiRequest(`/api/v1/schedules/${scheduleId}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listScheduleArtifacts(): Promise<ScheduleArtifact[]> {
|
||||
return apiRequest<ScheduleArtifact[]>("/api/v1/schedule-artifacts");
|
||||
}
|
||||
|
||||
export async function hideScheduleArtifact(
|
||||
versionsId: string,
|
||||
): Promise<{
|
||||
versions_id: string;
|
||||
deleted: boolean;
|
||||
artifact_preserved: boolean;
|
||||
}> {
|
||||
return apiRequest(`/api/v1/versions/${versionsId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEmployees(): Promise<Employee[]> {
|
||||
return apiRequest<Employee[]>("/api/v1/admin/employees");
|
||||
}
|
||||
|
||||
export async function createEmployee(input: {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
role_code: "admin" | "developer";
|
||||
}): Promise<Employee> {
|
||||
return apiRequest<Employee>("/api/v1/admin/employees", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEmployee(
|
||||
userId: string,
|
||||
input: {
|
||||
display_name?: string;
|
||||
email?: string | null;
|
||||
role_code?: "admin" | "developer";
|
||||
status?: "active" | "disabled" | "locked";
|
||||
},
|
||||
): Promise<Employee> {
|
||||
return apiRequest<Employee>(`/api/v1/admin/employees/${userId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEmployee(
|
||||
userId: string,
|
||||
): Promise<{ user_id: string; deleted: boolean }> {
|
||||
return apiRequest(`/api/v1/admin/employees/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createScheduleNode(
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
node_key: string;
|
||||
node_name: string;
|
||||
versions_id: string;
|
||||
timeout_seconds?: number;
|
||||
retry_count?: number;
|
||||
retry_interval_sec?: number;
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
arguments_json?: Record<string, unknown>;
|
||||
env_refs_json?: Record<string, string>;
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/nodes`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateScheduleNode(
|
||||
scheduleId: string,
|
||||
nodeId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
node_name?: string;
|
||||
versions_id?: string;
|
||||
timeout_seconds?: number;
|
||||
retry_count?: number;
|
||||
retry_interval_sec?: number;
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
arguments_json?: Record<string, unknown>;
|
||||
env_refs_json?: Record<string, string>;
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScheduleNode(
|
||||
scheduleId: string,
|
||||
nodeId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScheduleEdge(
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
source_node_id: string;
|
||||
target_node_id: string;
|
||||
condition_expr?: string | null;
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/edges`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteScheduleEdge(
|
||||
scheduleId: string,
|
||||
edgeId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/edges/${edgeId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateSchedule(
|
||||
scheduleId: string,
|
||||
): Promise<DagValidation & {
|
||||
schedule_id: string;
|
||||
workflow_version: number;
|
||||
}> {
|
||||
return apiRequest(`/api/v1/schedules/${scheduleId}/validate`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewCron(input: {
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
count?: number;
|
||||
base_time?: string;
|
||||
}): Promise<CronPreview> {
|
||||
return apiRequest<CronPreview>("/api/v1/cron/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runScheduleNow(
|
||||
scheduleId: string,
|
||||
): Promise<ScheduleRunDetail> {
|
||||
return apiRequest<ScheduleRunDetail>(
|
||||
`/api/v1/schedules/${scheduleId}/run`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Idempotency-Key": crypto.randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({ reason: "manual_run" }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listScheduleRuns(input: {
|
||||
scheduleId?: string;
|
||||
status?: ScheduleRunStatus;
|
||||
limit?: number;
|
||||
} = {}): Promise<ScheduleRunSummary[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (input.scheduleId) query.set("schedule_id", input.scheduleId);
|
||||
if (input.status) query.set("status", input.status);
|
||||
query.set("limit", String(input.limit ?? 20));
|
||||
return apiRequest<ScheduleRunSummary[]>(
|
||||
`/api/v1/schedule-runs?${query.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getScheduleRun(
|
||||
runId: string,
|
||||
): Promise<ScheduleRunDetail> {
|
||||
return apiRequest<ScheduleRunDetail>(`/api/v1/schedule-runs/${runId}`);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.dashboard-page,.admin-page{height:100%;padding:24px;overflow:auto;background:#f3f6f9}.dashboard-hero{display:flex;align-items:center;justify-content:space-between;padding:34px;border-radius:12px;color:#fff;background:linear-gradient(125deg,#0b3d69,#1978d4);box-shadow:0 12px 30px rgb(19 80 137/18%)}.dashboard-hero span{font-size:10px;letter-spacing:.12em;opacity:.82}.dashboard-hero h2{margin:8px 0 5px;font-size:25px}.dashboard-hero p{margin:0;font-size:12px;opacity:.88}.dashboard-hero__badge{padding:8px 12px;border-radius:20px;background:rgb(255 255 255/16%)}.dashboard-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px}.dashboard-metrics article{display:flex;align-items:center;gap:14px;padding:20px;border:1px solid #e0e8ef;border-radius:9px;background:#fff}.dashboard-metrics svg{color:#1978d4}.dashboard-metrics span{display:flex;flex-direction:column}.dashboard-metrics b{color:#20364c;font-size:21px}.dashboard-metrics small{color:#8796a6}.dashboard-actions{display:flex;gap:12px;margin-top:18px}.dashboard-actions button{display:flex;align-items:center;gap:8px;padding:13px 18px;border:1px solid #d8e4ef;border-radius:7px;color:#346587;background:#fff;cursor:pointer}.admin-page__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;padding:20px 22px;border:1px solid #dee7ef;border-radius:9px;background:#fff}.admin-page__header span{color:#1978d4;font-size:10px;font-weight:800}.admin-page__header h2{margin:4px 0;color:#20364c}.admin-page__header p{margin:0;color:#8a99a8;font-size:11px}.admin-readonly{margin-bottom:12px;padding:10px 13px;border:1px solid #f1d79c;border-radius:6px;color:#8a6416;background:#fff8e8;font-size:11px}.employee-table{overflow:hidden;border:1px solid #dfe7ee;border-radius:9px;background:#fff}.employee-table__head,.employee-row{display:grid;grid-template-columns:1.5fr 1fr .7fr .65fr .8fr;align-items:center;gap:12px;padding:12px 17px}.employee-table__head{color:#748598;background:#f5f8fb;font-size:10px;font-weight:700}.employee-row{min-height:64px;border-top:1px solid #edf1f5;color:#44576a;font-size:11px}.employee-name{display:flex;align-items:center;gap:10px}.employee-name .avatar{display:grid;width:32px;height:32px;place-items:center}.employee-name>span{display:flex;min-width:0;flex-direction:column}.employee-name strong{color:#23384e}.employee-name small{margin-top:3px;color:#93a0ae}.employee-row code{color:#60758b}.role-pill,.status-pill{width:max-content;padding:4px 8px;border-radius:12px}.role-pill{color:#1d6ab3;background:#eaf4ff}.role-pill.is-admin{color:#87580f;background:#fff2d6}.status-pill{color:#138657;background:#e8f8f1}.status-pill.is-disabled,.status-pill.is-locked{color:#a64b4b;background:#ffeded}.employee-actions{display:flex;gap:6px}.employee-actions button{padding:5px 9px;border:1px solid #d9e3ec;border-radius:4px;color:#4c6c88;background:#fff;cursor:pointer}.employee-actions button.is-danger{color:#c14a4a}.employee-actions button:disabled{cursor:not-allowed;opacity:.45}.admin-empty{padding:25px;text-align:center;color:#8796a6}
|
||||
@@ -0,0 +1,185 @@
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(310px, .9fr);
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
.dashboard-panel {
|
||||
border: 1px solid #dfe7ee;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 5px 16px rgb(33 65 97 / 5%);
|
||||
}
|
||||
.dashboard-panel > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 17px 20px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
.dashboard-panel header span {
|
||||
color: #7890a6;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .09em;
|
||||
}
|
||||
.dashboard-panel h3 {
|
||||
margin: 4px 0 0;
|
||||
color: #263b50;
|
||||
font-size: 14px;
|
||||
}
|
||||
.dashboard-panel--trend > header > b {
|
||||
padding: 5px 9px;
|
||||
border-radius: 13px;
|
||||
color: #16855a;
|
||||
background: #e8f8f1;
|
||||
font-size: 10px;
|
||||
}
|
||||
.trend-chart {
|
||||
display: flex;
|
||||
height: 190px;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 26px 28px 17px;
|
||||
background: repeating-linear-gradient(to bottom, #fff 0, #fff 44px, #eef3f7 45px);
|
||||
}
|
||||
.trend-column {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.trend-column i {
|
||||
display: block;
|
||||
width: min(36px, 72%);
|
||||
min-height: 12px;
|
||||
border-radius: 5px 5px 2px 2px;
|
||||
background: linear-gradient(#49a0eb, #207bd1);
|
||||
box-shadow: 0 4px 9px rgb(32 123 209 / 18%);
|
||||
}
|
||||
.trend-column__value, .trend-column small {
|
||||
color: #7890a3;
|
||||
font-size: 9px;
|
||||
}
|
||||
.dashboard-panel--trend footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 22px;
|
||||
padding: 10px;
|
||||
color: #708397;
|
||||
font-size: 9px;
|
||||
}
|
||||
.dashboard-panel footer span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.legend-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.legend-dot.is-success { background: #27b87c; }
|
||||
.legend-dot.is-failed { background: #ed7777; }
|
||||
.legend-dot.is-notebook { background: #318de0; }
|
||||
.legend-dot.is-python { background: #72c9b0; }
|
||||
.legend-dot.is-version { background: #f3b75c; }
|
||||
.donut-layout {
|
||||
display: flex;
|
||||
min-height: 226px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
padding: 20px;
|
||||
}
|
||||
.donut-chart {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(#318de0 0 67%, #72c9b0 67% 86%, #f3b75c 86% 100%);
|
||||
}
|
||||
.donut-chart::after {
|
||||
position: absolute;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
content: "";
|
||||
}
|
||||
.donut-chart span {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.donut-chart b { color: #273d53; font-size: 20px; }
|
||||
.donut-chart small { color: #8a9aaa; font-size: 8px; }
|
||||
.donut-legend { display: flex; flex-direction: column; gap: 15px; }
|
||||
.donut-legend > span {
|
||||
display: grid;
|
||||
grid-template-columns: 8px 1fr;
|
||||
align-items: center;
|
||||
column-gap: 7px;
|
||||
}
|
||||
.donut-legend b { color: #40566c; font-size: 10px; }
|
||||
.donut-legend small { grid-column: 2; color: #8a9baa; font-size: 8px; }
|
||||
.dashboard-panel--activity { grid-column: 1 / -1; }
|
||||
.dashboard-panel--activity > header > button {
|
||||
border: 0;
|
||||
color: #257bcb;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.activity-table__head, .activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr .7fr .55fr .5fr;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 11px 20px;
|
||||
}
|
||||
.activity-table__head {
|
||||
color: #8595a5;
|
||||
background: #f7f9fb;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.activity-row {
|
||||
border-top: 1px solid #edf1f5;
|
||||
color: #607488;
|
||||
font-size: 10px;
|
||||
}
|
||||
.activity-row > span:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #344c63;
|
||||
}
|
||||
.activity-icon {
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: #277fcc;
|
||||
background: #eaf4ff;
|
||||
}
|
||||
.activity-row b {
|
||||
padding: 3px 7px;
|
||||
border-radius: 10px;
|
||||
color: #16855a;
|
||||
background: #e9f8f2;
|
||||
font-size: 9px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-panel--activity { grid-column: auto; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-router/node": "^8",
|
||||
"@react-router/serve": "^8",
|
||||
"isbot": "^5.1.36",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router": "^8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.15.1"
|
||||
}
|
||||
Generated
+2327
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,7 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"include": [
|
||||
"**/*",
|
||||
"**/.server/**/*",
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [reactRouter()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8081",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/jupyter": {
|
||||
target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8081",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user