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
@@ -9,8 +9,7 @@ import ConversationSection from "./sections/ConversationSection";
|
||||
import TaskDetailPanel from "./sections/TaskDetailPanel";
|
||||
import ApiKeySection from "./sections/ApiKeySection";
|
||||
import AdminSection from "./sections/AdminSection";
|
||||
import ProjectsSection from "./sections/ProjectsSection";
|
||||
import MembersPanel from "./sections/MembersPanel";
|
||||
import ProjectNav from "./sections/ProjectNav";
|
||||
import EnvSection from "./sections/EnvSection";
|
||||
import EnvContextPanel from "./sections/EnvContextPanel";
|
||||
import WorkersSection from "./sections/WorkersSection";
|
||||
@@ -25,7 +24,10 @@ import TodosSection from "./sections/TodosSection";
|
||||
// data-cards row on top of a long stacked page, no detail stack
|
||||
// hanging under the conversation. The new shell is an IDE-style
|
||||
// workspace that never scrolls as one page:
|
||||
// 左侧栏:导航(按工作流分组)+ 数据卡片(紧凑版,始终可见)
|
||||
// 左侧栏:导航(按工作流分组)+ 项目模块(ProjectNav:项目列表 +
|
||||
// 创建项目 + 成员,id nav-projects,是全站唯一的项目选择器;
|
||||
// 因此不再有独立的“项目与成员”视图,旧 ProjectsSection 已删,
|
||||
// 工作台也不再带项目选择器)+ 数据卡片(紧凑版,始终可见)
|
||||
// 任务工作台:左 = 任务列表轨(搜索/筛选/分页),中 = 派发 + 对话,
|
||||
// 右 = 任务详情检查器(概览/协作/评审与审批/线程四个内页签,
|
||||
// 见 TaskDetailPanel)
|
||||
@@ -36,11 +38,10 @@ import TodosSection from "./sections/TodosSection";
|
||||
// unmounted with a view. The view is synced to location.hash
|
||||
// (#view-tasks etc.) so a reload or a shared link lands on the
|
||||
// same view; unknown hashes fall back to the task workspace.
|
||||
type ViewId = "tasks" | "todos" | "projects" | "resources" | "automation" | "admin";
|
||||
type ViewId = "tasks" | "todos" | "resources" | "automation" | "admin";
|
||||
const VIEWS: Array<[ViewId, string, string]> = [
|
||||
["tasks", "任务工作台", "任务"],
|
||||
["todos", "项目待办", "任务"],
|
||||
["projects", "项目与成员", "管理"],
|
||||
["resources", "环境与 Workers", "管理"],
|
||||
["automation", "技能与自动化", "管理"],
|
||||
["admin", "系统与 Key", "管理"],
|
||||
@@ -67,6 +68,9 @@ function SideNav({ view, setView }: { view: ViewId; setView: (v: ViewId) => void
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div id="nav-projects" className="shell-project-nav">
|
||||
<ProjectNav />
|
||||
</div>
|
||||
<div className="shell-stats">
|
||||
<div className="shell-stats-label">概览</div>
|
||||
<DataCards />
|
||||
@@ -138,7 +142,6 @@ export default function App() {
|
||||
) : (
|
||||
<main className="page">
|
||||
{view === "todos" ? (<><TodosSection /></>)
|
||||
: view === "projects" ? (<><ProjectsSection /><MembersPanel /></>)
|
||||
: view === "resources" ? (<><EnvSection /><EnvContextPanel /><WorkersSection /><HealthPanel /></>)
|
||||
: view === "automation" ? (<><SkillsSection /><SkillsToolsPanel /><AutomationsSection /><TriageSection /></>)
|
||||
: (<><AdminSection /><ApiKeySection /></>)}
|
||||
|
||||
@@ -93,6 +93,7 @@ export function memberErr(e: Error): string {
|
||||
if (m.includes("forbidden")) return "没有权限:只有项目 owner 或全局管理员可以管理成员";
|
||||
if (m.includes("already_member")) return "该用户已是成员,改角色请用成员行的下拉框";
|
||||
if (m.includes("cannot demote the only owner")) return "不能把唯一 owner 降为非 owner:请先把另一位成员升为 owner";
|
||||
if (m.includes("cannot remove the only owner")) return "不能移除唯一 owner:请先把另一位成员升为 owner";
|
||||
if (m.includes("user not found")) return "找不到该用户:请核对 user_id 或用户名是否正确";
|
||||
if (m.includes("member not found")) return "该用户不是本项目成员";
|
||||
if (m.includes("unknown role")) return "角色无效:只能是 viewer / contributor / maintainer / owner";
|
||||
|
||||
@@ -36,7 +36,9 @@ export interface Followup { mode?: string; status?: string; sender_task_id?: str
|
||||
export interface ChatHistoryMessage { id: string; role: "user" | "assistant" | "tool" | "status"; source?: string; kind?: string; label?: string; text: string; ts_ms?: number | null }
|
||||
export interface ChatHistory { task_id?: string; status?: string; messages: ChatHistoryMessage[]; continue_messages_stored?: number; legacy_continue_count?: number | null; trace_note?: string }
|
||||
export interface PeerMessage { task_id?: string; mode?: string; status?: string; body?: string }
|
||||
export interface Automation { id: string; name?: string; prompt?: string; repo_url?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number; last_run_at?: number; created_at?: number; cancelled_at?: number; run_once?: boolean }
|
||||
export interface Automation { id: string; name?: string; prompt?: string; repo_url?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number; last_run_at?: number; last_claimed_at?: number; created_by?: string; created_at?: number; cancelled_at?: number; first_run_at?: number; run_once?: boolean; project_id?: string }
|
||||
export interface AutomationRun { id?: string; automation_id?: string; project_id?: string; task_id?: string; status?: string; detail?: string; created_at?: number }
|
||||
export interface AutomationDetail extends Automation { runs?: AutomationRun[]; runs_note?: string; heartbeat_binding?: unknown }
|
||||
export interface Member { user_id: string; username?: string; role?: string }
|
||||
export interface SkillItem { name?: string; id?: string; description?: string; detail?: string }
|
||||
export interface McpServer { name?: string; id?: string; status?: string; detail?: string; details?: string; state?: string }
|
||||
@@ -45,7 +47,7 @@ export interface Agent { kind?: string; name?: string; status?: string; prompt?:
|
||||
export interface ThreadInfo { id?: string; name?: string; thread_name?: string; archived?: boolean | null; cwd?: string; turns?: number; turn_count?: number; turns_count?: number }
|
||||
export interface Triage {
|
||||
counts?: { automations_due?: number; failed_tasks?: number; pending_followups?: number; recent_runs?: number };
|
||||
automations_due?: Array<{ name?: string; interval_seconds?: number }>;
|
||||
automations_due?: Array<{ id?: string; name?: string; status?: string; due?: boolean; interval_seconds?: number; next_run_at?: number }>;
|
||||
failed_tasks?: Array<{ id?: string; status?: string; prompt?: string }>;
|
||||
pending_followups?: Array<{ task_id?: string; body?: string }>;
|
||||
recent_runs?: Array<{ automation_id?: string; status?: string; task_id?: string; task_status?: string }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Automation, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, TaskCost, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types";
|
||||
import type { Automation, AutomationDetail, Env, Member, Project, Review, Approval, Followup, PeerMessage, Task, TaskCost, ThreadInfo, Triage, Worker, SkillItem, McpServer, Agent, DispatchBody, ReviewBody, TraceEntry, ChatHistory } from "./api/types";
|
||||
|
||||
export interface ConsoleCtx {
|
||||
keySaved: boolean; keyState: string;
|
||||
@@ -7,13 +7,13 @@ export interface ConsoleCtx {
|
||||
quotaText: string;
|
||||
healthText: string; refreshHealth: () => void;
|
||||
adminGate: string; adminBody: boolean; adminResult: string; adminUserId: string; setAdminUserId: (v: string) => void;
|
||||
adminQuota: () => void; adminSweep: () => void; adminBackup: () => void;
|
||||
adminQuota: () => void; adminSetQuota: (uid: string, usdText: string) => void; adminSweep: () => void; adminBackup: () => void;
|
||||
projects: Project[]; projectId: string; projectMsg: string;
|
||||
selectProject: (id: string) => void; refreshProjects: () => void;
|
||||
createProject: (name: string, repo: string) => Promise<void>;
|
||||
githubRepos: Array<{ clone_url: string; repo_full_name: string }>;
|
||||
workers: Worker[] | null; workersSummary: string; workersError: string; refreshWorkers: () => void;
|
||||
members: Member[]; memberMsg: string; memberError: string; refreshMembers: () => void; inviteMember: (raw: string, role: string) => Promise<void>; changeMemberRole: (uid: string, role: string) => Promise<void>;
|
||||
members: Member[]; memberMsg: string; memberError: string; refreshMembers: () => void; inviteMember: (raw: string, role: string) => Promise<void>; changeMemberRole: (uid: string, role: string) => Promise<void>; removeMember: (uid: string) => void;
|
||||
skills: SkillItem[]; apps: SkillItem[]; mcpServers: McpServer[]; skillMsg: string; skillError: string; mcpMsg: string; mcpOauthMsg: string; mcpOauthBox: string;
|
||||
refreshSkills: () => void; reloadMcp: () => void; mcpOauth: (name: string) => Promise<void>;
|
||||
envs: Env[]; envCount: string; envMsg: string; envRepoLine: string; envRepoBar: boolean; envRepoMsg: string; envSelect: string; setEnvSelect: (v: string) => void;
|
||||
@@ -35,7 +35,7 @@ export interface ConsoleCtx {
|
||||
followups: Followup[];
|
||||
approvals: Approval[]; approvalMsg: string; refreshApprovals: () => void; decideApproval: (id: string, decision: string) => void;
|
||||
peerMessages: PeerMessage[]; peerMsg: string; postPeer: (mode: string, target: string, text: string) => Promise<void>;
|
||||
automations: Automation[]; autoMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; updateAutomation: (a: Automation, name: string, prompt: string, interval: string, repo: string) => Promise<boolean>; cancelAutomation: (id: string) => void;
|
||||
automations: Automation[]; autoMsg: string; autoDetail: AutomationDetail | null; autoDetailMsg: string; refreshAutomations: () => void; createAutomation: (name: string, prompt: string, interval: string, repo: string) => Promise<void>; updateAutomation: (a: Automation, name: string, prompt: string, interval: string, repo: string) => Promise<boolean>; cancelAutomation: (id: string) => void; loadAutomationDetail: (id: string) => void; closeAutomationDetail: () => void;
|
||||
triage: Triage | null; triageError: string; refreshTriage: () => void;
|
||||
threadText: string; threadGateNote: string; threadMsg: string; threadList: ThreadInfo[]; threadAction: (kind: string, name: string) => void;
|
||||
dispatch: (body: DispatchBody, atts: Array<{ path: string; file: File }>) => Promise<string>; dispatchMsg: string;
|
||||
|
||||
@@ -253,6 +253,17 @@ export function useCatalog() {
|
||||
} catch (e: any) { setMemberMsg(memberErr(e)); }
|
||||
}, []);
|
||||
|
||||
const removeMember = useCallback(async (uid: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
if (!window.confirm("确定移除该成员吗?移除后其项目权限立即失效(唯一 owner 不可移除,服务端会拒绝)。")) return;
|
||||
try {
|
||||
await api(paths.projectMember(pid, uid), "DELETE");
|
||||
setMemberMsg("成员已移除");
|
||||
await refreshMembers(pid);
|
||||
} catch (e: any) { setMemberMsg(memberErr(e)); }
|
||||
}, [refreshMembers]);
|
||||
|
||||
const reloadMcp = useCallback(async () => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) { setMcpMsg("先选项目"); return; }
|
||||
@@ -289,6 +300,24 @@ export function useCatalog() {
|
||||
} catch (e: any) { setAdminResult(adminErr(e)); }
|
||||
}, [adminUserId]);
|
||||
|
||||
const adminSetQuota = useCallback(async (uid: string, usdText: string) => {
|
||||
const target = uid.trim();
|
||||
if (!target) { setAdminResult("请填要设置配额的用户 id"); return; }
|
||||
// POST /admin/quotas {user_id, quota_usd_micros}: integer micros or
|
||||
// null for unlimited, >= 0. The console takes dollars and converts.
|
||||
let micros: number | null;
|
||||
if (!usdText.trim()) micros = null;
|
||||
else {
|
||||
const usd = Number(usdText.trim());
|
||||
if (!Number.isFinite(usd) || usd < 0) { setAdminResult("配额金额无效:请输入 >= 0 的美元数,留空表示不限额"); return; }
|
||||
micros = Math.round(usd * 1000000);
|
||||
}
|
||||
try {
|
||||
const d = await api<{ used_usd_micros?: number; quota_usd_micros?: number | null; remaining_usd_micros?: number | null; month?: string }>(paths.adminQuotas, "POST", { user_id: target, quota_usd_micros: micros });
|
||||
setAdminResult("配额已设置:用户 " + target.slice(0, 8) + ":本月用量 " + fmtUsd(d.used_usd_micros) + " / 配额 " + fmtUsd(d.quota_usd_micros) + (d.remaining_usd_micros !== null && d.remaining_usd_micros !== undefined ? "(剩余 " + fmtUsd(d.remaining_usd_micros) + ")" : "") + " | 月份 " + (d.month || ""));
|
||||
} catch (e: any) { setAdminResult(adminErr(e)); }
|
||||
}, []);
|
||||
|
||||
const adminSweep = useCallback(async () => {
|
||||
if (!window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。")) return;
|
||||
try {
|
||||
@@ -314,6 +343,6 @@ export function useCatalog() {
|
||||
models, modelCustomVisible,
|
||||
refreshHealth, loadQuota, probeAdmin, refreshProjects, loadGithubRepos, loadRelayModels, refreshWorkers,
|
||||
refreshEnvs, refreshMembers, refreshSkills, saveKeyInput, clearKeyInput, createProject, createEnv,
|
||||
archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, reloadMcp, mcpOauth, adminQuota, adminSweep, adminBackup,
|
||||
archiveEnv, saveEnvRepo, inviteMember, changeMemberRole, removeMember, reloadMcp, mcpOauth, adminQuota, adminSetQuota, adminSweep, adminBackup,
|
||||
};
|
||||
}
|
||||
@@ -108,14 +108,14 @@ export function useConsoleStore(): ConsoleCtx {
|
||||
healthText: catalog.healthText, refreshHealth: catalog.refreshHealth,
|
||||
adminGate: catalog.adminGate, adminBody: catalog.adminBody, adminResult: catalog.adminResult,
|
||||
adminUserId: catalog.adminUserId, setAdminUserId: catalog.setAdminUserId,
|
||||
adminQuota: catalog.adminQuota, adminSweep: catalog.adminSweep, adminBackup: catalog.adminBackup,
|
||||
adminQuota: catalog.adminQuota, adminSetQuota: catalog.adminSetQuota, adminSweep: catalog.adminSweep, adminBackup: catalog.adminBackup,
|
||||
projects: catalog.projects, projectId: catalog.projectId, projectMsg: catalog.projectMsg,
|
||||
selectProject, refreshProjects: catalog.refreshProjects,
|
||||
createProject: catalog.createProject,
|
||||
githubRepos: catalog.githubRepos,
|
||||
workers: catalog.workers, workersSummary: catalog.workersSummary, workersError: catalog.workersError, refreshWorkers: catalog.refreshWorkers,
|
||||
members: catalog.members, memberMsg: catalog.memberMsg, memberError: catalog.memberError,
|
||||
refreshMembers: () => void catalog.refreshMembers(), inviteMember: catalog.inviteMember, changeMemberRole: catalog.changeMemberRole,
|
||||
refreshMembers: () => void catalog.refreshMembers(), inviteMember: catalog.inviteMember, changeMemberRole: catalog.changeMemberRole, removeMember: catalog.removeMember,
|
||||
skills: catalog.skills, apps: catalog.apps, mcpServers: catalog.mcpServers, skillMsg: catalog.skillMsg, skillError: catalog.skillError,
|
||||
mcpMsg: catalog.mcpMsg, mcpOauthMsg: catalog.mcpOauthMsg, mcpOauthBox: catalog.mcpOauthBox,
|
||||
refreshSkills: () => void catalog.refreshSkills(), reloadMcp: catalog.reloadMcp, mcpOauth: catalog.mcpOauth,
|
||||
@@ -144,7 +144,7 @@ export function useConsoleStore(): ConsoleCtx {
|
||||
followups: detail.followups,
|
||||
approvals: detail.approvals, approvalMsg: detail.approvalMsg, refreshApprovals: detail.refreshApprovals, decideApproval: detail.decideApproval,
|
||||
peerMessages: detail.peerMessages, peerMsg: detail.peerMsg, postPeer: detail.postPeer,
|
||||
automations: tools.automations, autoMsg: tools.autoMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, updateAutomation: tools.updateAutomation, cancelAutomation: tools.cancelAutomation,
|
||||
automations: tools.automations, autoMsg: tools.autoMsg, autoDetail: tools.autoDetail, autoDetailMsg: tools.autoDetailMsg, refreshAutomations: () => void tools.refreshAutomations(), createAutomation: tools.createAutomation, updateAutomation: tools.updateAutomation, cancelAutomation: tools.cancelAutomation, loadAutomationDetail: tools.loadAutomationDetail, closeAutomationDetail: tools.closeAutomationDetail,
|
||||
triage: tools.triage, triageError: tools.triageError, refreshTriage: () => void tools.refreshTriage(),
|
||||
threadText: detail.threadText, threadGateNote: detail.threadGateNote, threadMsg: detail.threadMsg, threadList: detail.threadList, threadAction: detail.threadAction,
|
||||
dispatch: async (body, atts) => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { paths } from "../api/paths";
|
||||
import type { Automation, Triage } from "../api/types";
|
||||
import type { Automation, AutomationDetail, Triage } from "../api/types";
|
||||
|
||||
// Project-scoped tools shown in the centre for their own views:
|
||||
// automations (create/list/cancel) and the triage inbox (4 groups).
|
||||
export function useProjectTools(getProjectId: () => string) {
|
||||
const [automations, setAutomations] = useState<Automation[]>([]);
|
||||
const [autoMsg, setAutoMsg] = useState("");
|
||||
const [autoDetail, setAutoDetail] = useState<AutomationDetail | null>(null);
|
||||
const [autoDetailMsg, setAutoDetailMsg] = useState("");
|
||||
const [triage, setTriage] = useState<Triage | null>(null);
|
||||
const [triageError, setTriageError] = useState("");
|
||||
|
||||
@@ -66,10 +68,26 @@ export function useProjectTools(getProjectId: () => string) {
|
||||
const pid = getProjectId();
|
||||
if (!pid) return;
|
||||
void api(paths.projectAutomationCancel(pid, id), "POST", {})
|
||||
.then(() => { setAutoMsg("自动化已取消"); void refreshAutomations(pid); })
|
||||
.then(() => { setAutoMsg("自动化已取消"); setAutoDetail((prev) => (prev && prev.id === id ? null : prev)); void refreshAutomations(pid); })
|
||||
.catch((e: any) => setAutoMsg("取消失败:" + e.message));
|
||||
}, [getProjectId, refreshAutomations]);
|
||||
|
||||
// GET /projects/{pid}/automations/{aid}: full definition + recent
|
||||
// runs (+ runs_note, heartbeat_binding). Verified against origin/main
|
||||
// cloud/server.py _automation_get / cloud/automations.py get_automation.
|
||||
const loadAutomationDetail = useCallback(async (id: string) => {
|
||||
const pid = getProjectId();
|
||||
if (!pid) { setAutoDetailMsg("先选项目"); return; }
|
||||
try {
|
||||
const d = await api<AutomationDetail>(paths.projectAutomation(pid, id));
|
||||
setAutoDetail(d);
|
||||
setAutoDetailMsg("");
|
||||
} catch (e: any) { setAutoDetailMsg("详情加载失败:" + e.message); }
|
||||
}, [getProjectId]);
|
||||
|
||||
const closeAutomationDetail = useCallback(() => { setAutoDetail(null); setAutoDetailMsg(""); }, []);
|
||||
|
||||
|
||||
const refreshTriage = useCallback(async (pid?: string) => {
|
||||
const id = pid !== undefined ? pid : getProjectId();
|
||||
if (!id) { setTriage(null); return; }
|
||||
@@ -80,5 +98,5 @@ export function useProjectTools(getProjectId: () => string) {
|
||||
} catch (e: any) { setTriageError("分诊加载失败:" + e.message); }
|
||||
}, [getProjectId]);
|
||||
|
||||
return { automations, autoMsg, triage, triageError, refreshAutomations, createAutomation, updateAutomation, cancelAutomation, refreshTriage };
|
||||
return { automations, autoMsg, autoDetail, autoDetailMsg, triage, triageError, refreshAutomations, createAutomation, updateAutomation, cancelAutomation, loadAutomationDetail, closeAutomationDetail, refreshTriage };
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -254,7 +254,7 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
|
||||
which is still tight — just in a different order. */
|
||||
.dash-col{display:contents}
|
||||
#sec-dispatch{order:1} #sec-tasks{order:2} #sec-conv{order:3}
|
||||
#sec-projects{order:4} #sec-env{order:5} #sec-workers{order:6}
|
||||
#sec-env{order:5} #sec-workers{order:6}
|
||||
#sec-skills{order:7} #sec-automations{order:8} #sec-triage{order:9}
|
||||
#sec-admin{order:10} #sec-key{order:11}
|
||||
.dash-cards{grid-template-columns:repeat(2,1fr)}
|
||||
@@ -285,6 +285,20 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
|
||||
.shell-nav button{text-align:left;border:0;box-shadow:none;background:transparent;padding:8px;border-radius:var(--radius);font-weight:500}
|
||||
.shell-nav button:hover{background:#fff}
|
||||
.shell-nav button.active{background:var(--text);color:#fff}
|
||||
/* Project sidebar (ProjectNav): the one project selector for the whole
|
||||
console — project list + create + members — living in the IDE
|
||||
shell's left sidebar. Same Primer tokens as the rest of the shell;
|
||||
the sidebar is 260px wide, so its bars stack full-width. */
|
||||
.shell-project-nav{padding:0 12px 16px;min-width:0}
|
||||
.shell-project-nav .project-nav{display:flex;flex-direction:column;gap:16px;min-width:0}
|
||||
.shell-project-nav .project-nav-list{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
||||
.shell-project-nav .project-nav-item{padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--line);background:#fff}
|
||||
.shell-project-nav .project-nav-item:last-child{border-bottom:0}
|
||||
.shell-project-nav .project-nav-item:hover{background:var(--subtle-bg)}
|
||||
.shell-project-nav .project-nav-item.selected{background:var(--selected-bg)}
|
||||
.shell-project-nav .project-nav-name{display:block;font-size:14px;font-weight:600;overflow-wrap:anywhere}
|
||||
.shell-project-nav .project-nav-meta{display:block;font-size:12px;color:var(--muted)}
|
||||
.shell-project-nav .bar > input[type="text"],.shell-project-nav .bar > select{flex:1 1 100%}
|
||||
.shell-stats{margin-top:auto;border-top:1px solid var(--line);padding:16px 12px}
|
||||
.shell-stats-label{font-size:12px;font-weight:600;color:var(--muted);padding:0 4px 8px}
|
||||
.shell-stats .dash-cards{display:flex;flex-direction:column;gap:8px}
|
||||
@@ -316,6 +330,7 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
|
||||
.shell-side{flex-direction:row;flex-wrap:wrap;align-items:flex-start;border-right:0;border-bottom:1px solid var(--line);overflow:visible}
|
||||
.shell-nav{flex-direction:row;flex-wrap:wrap}
|
||||
.shell-nav-group{flex-direction:row;align-items:center}
|
||||
.shell-project-nav{flex:1 1 100%}
|
||||
.shell-stats{display:none}
|
||||
.shell-hide-sm{display:none}
|
||||
.workspace{grid-template-columns:1fr}
|
||||
|
||||
Reference in new issue
Block a user