console: 项目导航移到左侧栏,工作台不再承担项目模块
左侧栏 ProjectNav:项目完整列表(名 + 短 id + 我的角色, 选中高亮,点击切换)+ 创建项目(名/GitHub 仓库/repo_url) + 选中项目成员(邀请/改角色/新增移除);工作台删除 sec-projects 一节与任务列表里的项目下拉框,侧栏为唯一 项目选择器,自动选第一个项目逻辑不变。 薄模块补齐(均为已核实后端路由):成员移除 DELETE、 自动化详情(GET 单条 + 产出)与编辑 PATCH、分诊条目点进 任务/自动化详情、Workers 搜索与在线筛选、管理区设置配额 POST。主线新功能(Todos、Browser、taskMeta 等)原样保留, 未做任何回退。console 标记单测本地 53 项通过。
This commit is contained in:
1 parent
d6369154c2
commit
08484302ac
16 files changed
+293
-100
No files matched your search
@@ -1,8 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
|
||||
export default function AdminSection() {
|
||||
const ctx = useConsole();
|
||||
const [setUser, setSetUser] = useState("");
|
||||
const [setUsd, setSetUsd] = useState("");
|
||||
return (
|
||||
<Card id="adminCard" title={<>管理区 <span className="muted">(仅全局管理员)</span></>}>
|
||||
<div className="muted">{ctx.adminGate}</div>
|
||||
@@ -12,6 +15,11 @@ export default function AdminSection() {
|
||||
<input type="text" placeholder="要查配额的用户 id" value={ctx.adminUserId} onChange={(e) => ctx.setAdminUserId(e.target.value)} />
|
||||
<button onClick={ctx.adminQuota}>查该用户配额</button>
|
||||
</Bar>
|
||||
<Bar>
|
||||
<input type="text" placeholder="要设置配额的用户 id" value={setUser} onChange={(e) => setSetUser(e.target.value)} />
|
||||
<input type="text" placeholder="美元配额(留空 = 不限额)" value={setUsd} onChange={(e) => setSetUsd(e.target.value)} />
|
||||
<button className="primary" onClick={() => ctx.adminSetQuota(setUser, setUsd)}>设置配额</button>
|
||||
</Bar>
|
||||
<Bar>
|
||||
<button onClick={ctx.adminSweep}>手动触发 sweep(清理陈旧 running 任务)</button>
|
||||
<button onClick={ctx.adminBackup}>手动触发备份</button>
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function MembersPanel() {
|
||||
<select aria-label="成员角色" value={m.role || "viewer"} onChange={(e) => void ctx.changeMemberRole(m.user_id, e.target.value)}>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
<button onClick={() => void ctx.removeMember(m.user_id)}>移除</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
import { shortId } from "../api/client";
|
||||
import MembersPanel from "./MembersPanel";
|
||||
|
||||
// Left sidebar: the one project selector for the whole console.
|
||||
// Lists every project (name + short id + my role), click selects it;
|
||||
// below the list is the create-project form (same fields as the old
|
||||
// workbench ProjectsSection: name, GitHub repo select, repo_url) and
|
||||
// the selected project's members. The workbench no longer carries any
|
||||
// project-module UI; it only consumes ctx.projectId.
|
||||
export default function ProjectNav() {
|
||||
const ctx = useConsole();
|
||||
const [name, setName] = useState("");
|
||||
const [repoSelect, setRepoSelect] = useState("");
|
||||
const [repo, setRepo] = useState("");
|
||||
return (
|
||||
<div className="project-nav">
|
||||
<Card title="项目">
|
||||
<Bar><button onClick={ctx.refreshProjects}>刷新项目</button></Bar>
|
||||
{ctx.projects.length === 0 ? <div className="muted">暂无项目</div> : (
|
||||
<ul className="project-nav-list">
|
||||
{ctx.projects.map((p) => (
|
||||
<li key={p.id} className={"project-nav-item" + (p.id === ctx.projectId ? " selected" : "")} onClick={() => ctx.selectProject(p.id)} title={p.id}>
|
||||
<span className="project-nav-name">{p.name}</span>
|
||||
<span className="project-nav-meta">{shortId(p.id)}{p.my_role ? " · " + p.my_role : ""}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<h3>创建项目</h3>
|
||||
<Bar>
|
||||
<input type="text" placeholder="新项目名" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Bar>
|
||||
<Bar>
|
||||
<select aria-label="从 GitHub 仓库选" value={repoSelect} onChange={(e) => { setRepoSelect(e.target.value); if (e.target.value) setRepo(e.target.value); }}>
|
||||
<option value="">(从 GitHub App 安装仓库里选,或手动输入)</option>
|
||||
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
|
||||
</select>
|
||||
</Bar>
|
||||
<Bar>
|
||||
<input type="text" placeholder="repo_url(可选)" value={repo} onChange={(e) => setRepo(e.target.value)} />
|
||||
<button className="primary" onClick={async () => { await ctx.createProject(name, repo); setName(""); setRepo(""); setRepoSelect(""); }}>创建项目</button>
|
||||
</Bar>
|
||||
<span className="muted">{ctx.projectMsg}</span>
|
||||
</Card>
|
||||
<MembersPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar } from "../components/ui";
|
||||
import { shortId } from "../api/client";
|
||||
|
||||
export default function ProjectsSection() {
|
||||
const ctx = useConsole();
|
||||
const [name, setName] = useState("");
|
||||
const [repoSelect, setRepoSelect] = useState("");
|
||||
const [repo, setRepo] = useState("");
|
||||
return (
|
||||
<Card id="nav-projects" title="项目">
|
||||
<Bar>
|
||||
<select aria-label="项目" value={ctx.projectId} onChange={(e) => ctx.selectProject(e.target.value)}>
|
||||
{ctx.projects.length === 0 ? <option value="">(先加载项目)</option> : null}
|
||||
{ctx.projects.map((p) => <option key={p.id} value={p.id}>{p.name} ({shortId(p.id)})</option>)}
|
||||
</select>
|
||||
<button onClick={ctx.refreshProjects}>刷新</button>
|
||||
</Bar>
|
||||
<Bar>
|
||||
<input type="text" placeholder="新项目名" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<select aria-label="从 GitHub 仓库选" value={repoSelect} onChange={(e) => { setRepoSelect(e.target.value); if (e.target.value) setRepo(e.target.value); }}>
|
||||
<option value="">(从 GitHub App 安装仓库里选,或手动输入)</option>
|
||||
{ctx.githubRepos.map((r) => <option key={r.clone_url} value={r.clone_url}>{r.repo_full_name}</option>)}
|
||||
</select>
|
||||
<input type="text" placeholder="repo_url(可选)" value={repo} onChange={(e) => setRepo(e.target.value)} />
|
||||
<button onClick={async () => { await ctx.createProject(name, repo); setName(""); setRepo(""); setRepoSelect(""); }}>新建项目</button>
|
||||
</Bar>
|
||||
<span className="muted">{ctx.projectMsg}</span>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import { firstChar, durationOf, tokenOf } from "../api/taskMeta";
|
||||
// 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts,
|
||||
// 与任务详情共用同一口径。
|
||||
|
||||
// GitHub issues-style task list: search on top, the project selector
|
||||
// (the auto-project fix — do not remove it), a 主要聊天 header with a
|
||||
// + button, then hairline rows with a Primer status dot at the left,
|
||||
// GitHub issues-style task list: search on top, a 主要聊天 header with
|
||||
// a + button, then hairline rows with a Primer status dot at the
|
||||
// left. There is deliberately NO project selector here any more:
|
||||
// the left sidebar (ProjectNav) is the one project selector, and the
|
||||
// auto-select-first-project fix in the store keeps the list from
|
||||
// ever starting empty.
|
||||
// a 14px title and a 12px muted meta line. The search input IS the
|
||||
// existing task search (id/prompt); status and executor filters stay
|
||||
// as a compact row below it. The selected row gets the light
|
||||
@@ -22,12 +25,6 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
|
||||
</svg>
|
||||
<input type="text" placeholder="搜索" aria-label="搜索任务" value={ctx.searchText} onChange={(e) => ctx.setSearchText(e.target.value)} />
|
||||
</div>
|
||||
<div className="bar">
|
||||
<select aria-label="项目" value={ctx.projectId} onChange={(e) => ctx.selectProject(e.target.value)}>
|
||||
{ctx.projects.length === 0 ? <option value="">(先加载项目)</option> : null}
|
||||
{ctx.projects.map((p) => <option key={p.id} value={p.id}>{p.name} ({shortId(p.id)})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="list-section-head">
|
||||
<span>主要聊天</span>
|
||||
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
|
||||
|
||||
@@ -1,22 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { useConsole } from "../context";
|
||||
import { Card, Bar, DataListRaw } from "../components/ui";
|
||||
import { workerAgeText, shortId } from "../api/client";
|
||||
|
||||
// Workers are read-only by design (GET /workers + worker-side
|
||||
// heartbeat only — no start/stop/kill actions exist to surface), so
|
||||
// production-grade here is list + status + refresh + frontend-only
|
||||
// filtering of the already-fetched list. Non-admin users only get
|
||||
// the online/offline aggregate from the backend (see _workers_list),
|
||||
// so search/filter apply to the full list only and say so.
|
||||
export default function WorkersSection() {
|
||||
const ctx = useConsole();
|
||||
const [query, setQuery] = useState("");
|
||||
const [onlineFilter, setOnlineFilter] = useState<"all" | "online" | "offline">("all");
|
||||
const isAlive = (w: { alive?: boolean | null; last_seen?: number; last_seen_seconds_ago?: number }) => {
|
||||
const info = workerAgeText(w);
|
||||
return w.alive !== undefined && w.alive !== null ? !!w.alive : (info.age !== null && info.age <= 120);
|
||||
};
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = (ctx.workers || []).filter((w) => {
|
||||
if (q && !(((w.worker_id || w.id || "") + " " + (w.executor || "")).toLowerCase().includes(q))) return false;
|
||||
if (onlineFilter === "online" && !isAlive(w)) return false;
|
||||
if (onlineFilter === "offline" && isAlive(w)) return false;
|
||||
return true;
|
||||
});
|
||||
return (
|
||||
<Card id="nav-workers" title="Workers">
|
||||
<Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar>
|
||||
<Bar>
|
||||
<input type="text" placeholder="搜索 worker_id / 执行器" aria-label="搜索 Worker" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
<select aria-label="在线状态筛选" value={onlineFilter} onChange={(e) => setOnlineFilter(e.target.value as "all" | "online" | "offline")}>
|
||||
<option value="all">在线状态:全部</option>
|
||||
<option value="online">仅在线</option>
|
||||
<option value="offline">仅离线</option>
|
||||
</select>
|
||||
{ctx.workers ? <span className="muted">显示 {filtered.length} / 共 {ctx.workers.length} 个</span> : null}
|
||||
</Bar>
|
||||
{ctx.workersError ? <div className="muted">{ctx.workersError}</div>
|
||||
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div>
|
||||
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}(完整清单仅全局管理员可见,搜索/筛选仅对完整清单生效)</div>
|
||||
: ctx.workers.length === 0 ? <div className="muted">无 worker 注册</div>
|
||||
: filtered.length === 0 ? <div className="muted">没有符合筛选条件的 worker</div>
|
||||
: (
|
||||
<DataListRaw>
|
||||
{ctx.workers.map((w, i) => {
|
||||
{filtered.map((w, i) => {
|
||||
const run = w.running === undefined || w.running === null ? "?" : w.running;
|
||||
const cap = w.capacity === undefined || w.capacity === null ? "?" : w.capacity;
|
||||
const info = workerAgeText(w);
|
||||
const alive = w.alive !== undefined && w.alive !== null ? !!w.alive : (info.age !== null && info.age <= 120);
|
||||
const alive = isAlive(w);
|
||||
const live = alive ? " / 在线" : " / 离线(占用为上次上报值)";
|
||||
return (
|
||||
<li key={i} className={alive ? "is-online" : "is-offline"}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useConsole } from "../../context";
|
||||
import { Bar, DataList } from "../../components/ui";
|
||||
import { fmt, shortId } from "../../api/client";
|
||||
import type { Automation } from "../../api/types";
|
||||
import type { Automation, AutomationDetail } from "../../api/types";
|
||||
|
||||
const PROMPT_PREVIEW = 160;
|
||||
|
||||
@@ -14,6 +14,49 @@ function humanInterval(s?: number): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Automation detail (read-only display): backed by GET
|
||||
// /projects/{pid}/automations/{aid} — full definition + runs[] +
|
||||
// runs_note + heartbeat_binding. Editing is NOT duplicated here:
|
||||
// the single edit form is main's inline edit in the list below
|
||||
// (updateAutomation with the (a, name, prompt, interval, repo)
|
||||
// signature). Detail display + runs + task buttons are our side's
|
||||
// addition; cross-view jumps use location.hash because the IDE
|
||||
// shell switches views on hashchange (no sec-conv / sec-automations
|
||||
// element to scroll to).
|
||||
function AutomationDetailBox({ detail }: { detail: AutomationDetail }) {
|
||||
const ctx = useConsole();
|
||||
const openTask = (id?: string) => {
|
||||
if (!id) return;
|
||||
ctx.showDetail(id);
|
||||
try { window.location.hash = "#view-tasks"; } catch { /* noop */ }
|
||||
};
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>自动化详情:{detail.name}</h2>
|
||||
<div className="muted">
|
||||
状态 {detail.status}{detail.due ? "|到期" : ""} | 每 {detail.interval_seconds} 秒 | 下次 {fmt(detail.next_run_at)}
|
||||
| 上次执行 {fmt(detail.last_run_at)} | 创建人 {detail.created_by ? shortId(detail.created_by) : ""} | 创建于 {fmt(detail.created_at)}
|
||||
{detail.cancelled_at ? " | 已取消于 " + fmt(detail.cancelled_at) : ""}
|
||||
{detail.heartbeat_binding ? " | 心跳绑定:已绑定" : ""}
|
||||
</div>
|
||||
<div className="muted" style={{ whiteSpace: "pre-wrap", marginTop: 8 }}>{detail.prompt || ""}</div>
|
||||
{detail.repo_url ? <div className="muted">仓库 {detail.repo_url}</div> : null}
|
||||
<Bar>
|
||||
<button onClick={ctx.closeAutomationDetail}>关闭详情</button>
|
||||
<span className="muted">编辑请在下面列表里点“编辑”。{ctx.autoDetailMsg}</span>
|
||||
</Bar>
|
||||
<div className="data-group"><b>最近产出({(detail.runs || []).length})</b>
|
||||
<DataList empty="(无)" items={(detail.runs || []).map((r, i) => (
|
||||
<span key={i} className="row-main">
|
||||
{r.status}{r.created_at ? "|" + fmt(r.created_at) : ""}{r.task_id ? <> | 任务 <button onClick={() => openTask(r.task_id as string)}>{shortId(r.task_id)}</button></> : ""}{r.detail ? "|" + String(r.detail).slice(0, 120) : ""}
|
||||
</span>
|
||||
))} />
|
||||
</div>
|
||||
{detail.runs_note ? <div className="muted">{detail.runs_note}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutomationPanel() {
|
||||
const ctx = useConsole();
|
||||
const [name, setName] = useState("");
|
||||
@@ -53,6 +96,7 @@ export function AutomationPanel() {
|
||||
<div key={a.id}>
|
||||
<div className="row-main">
|
||||
[{a.status}{a.due ? "|到期" : ""}{a.run_once ? "|一次性" : ""}] {a.name}|每 {a.interval_seconds} 秒{humanInterval(a.interval_seconds)}|上次 {fmt(a.last_run_at) || "未运行"}|下次 {fmt(a.next_run_at)}
|
||||
<button onClick={() => void ctx.loadAutomationDetail(a.id)}>详情</button>
|
||||
{a.status === "active" ? <> <button onClick={() => startEdit(a)}>编辑</button> <button onClick={() => ctx.cancelAutomation(a.id)}>取消</button></> : null}
|
||||
</div>
|
||||
{a.repo_url ? <div className="muted">仓库:{a.repo_url}</div> : null}
|
||||
@@ -80,6 +124,8 @@ export function AutomationPanel() {
|
||||
</div>
|
||||
);
|
||||
})} />
|
||||
{ctx.autoDetailMsg && !ctx.autoDetail ? <div className="muted">{ctx.autoDetailMsg}</div> : null}
|
||||
{ctx.autoDetail ? <AutomationDetailBox key={ctx.autoDetail.id} detail={ctx.autoDetail} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -87,21 +133,45 @@ export function AutomationPanel() {
|
||||
export function TriagePanel() {
|
||||
const ctx = useConsole();
|
||||
const t = ctx.triage;
|
||||
// Clicking an actionable item opens that task's detail (tasks view)
|
||||
// or the automation's detail (automation view). Field names are
|
||||
// exactly the triage payload's: failed_tasks[].id, pending_followups
|
||||
// [].task_id, recent_runs[].task_id (nullable), automations_due[].id.
|
||||
// Jumps use location.hash because the IDE shell switches views on
|
||||
// hashchange; there is no sec-conv / sec-automations to scroll to.
|
||||
const openTask = (id?: string) => {
|
||||
if (!id) return;
|
||||
ctx.showDetail(id);
|
||||
try { window.location.hash = "#view-tasks"; } catch { /* noop */ }
|
||||
};
|
||||
const openAutomation = (id?: string) => {
|
||||
if (!id) return;
|
||||
void ctx.loadAutomationDetail(id);
|
||||
try { window.location.hash = "#view-automation"; } catch { /* noop */ }
|
||||
};
|
||||
if (ctx.triageError) return <div className="muted">{ctx.triageError}</div>;
|
||||
if (!t) return <div className="muted">(先选项目)</div>;
|
||||
return (
|
||||
<>
|
||||
<div className="data-group"><b>到期自动化({t.counts?.automations_due || 0})</b>
|
||||
<DataList empty="(无)" items={(t.automations_due || []).map((a, i) => <span key={i} className="row-main">{a.name}|每 {a.interval_seconds} 秒</span>)} />
|
||||
<DataList empty="(无)" items={(t.automations_due || []).map((a, i) => (
|
||||
<span key={i} className="row-main">{a.id ? <button onClick={() => openAutomation(a.id)}>{a.name}|每 {a.interval_seconds} 秒</button> : <>{a.name}|每 {a.interval_seconds} 秒</>}</span>
|
||||
))} />
|
||||
</div>
|
||||
<div className="data-group"><b>失败/中断任务({t.counts?.failed_tasks || 0})</b>
|
||||
<DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => <span key={i} className="row-main">{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</span>)} />
|
||||
<DataList empty="(无)" items={(t.failed_tasks || []).map((x, i) => (
|
||||
<span key={i} className="row-main"><button onClick={() => openTask(x.id)}>{shortId(x.id)}|{x.status}|{String(x.prompt || "").slice(0, 80)}</button></span>
|
||||
))} />
|
||||
</div>
|
||||
<div className="data-group"><b>待处理后续消息({t.counts?.pending_followups || 0})</b>
|
||||
<DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => <span key={i} className="row-main">任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</span>)} />
|
||||
<DataList empty="(无)" items={(t.pending_followups || []).map((f, i) => (
|
||||
<span key={i} className="row-main"><button onClick={() => openTask(f.task_id)}>任务 {shortId(f.task_id)}|{String(f.body || "").slice(0, 80)}</button></span>
|
||||
))} />
|
||||
</div>
|
||||
<div className="data-group"><b>最近自动化产出({t.counts?.recent_runs || 0})</b>
|
||||
<DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => <span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? "|任务 " + shortId(r.task_id) : ""}{r.task_status ? "|" + r.task_status : ""}</span>)} />
|
||||
<DataList empty="(无)" items={(t.recent_runs || []).map((r, i) => (
|
||||
<span key={i} className="row-main">{shortId(r.automation_id)}|{r.status}{r.task_id ? <>|任务 <button onClick={() => openTask(r.task_id)}>{shortId(r.task_id)}</button></> : ""}{r.task_status ? "|" + r.task_status : ""}</span>
|
||||
))} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user