feat: 管理页 Agent 停用/恢复按钮
QuotaPanel 改名为「Agent 管理」,每行新增:
- 状态徽章(在线/离线/已停用)
- 停用/恢复按钮(点一下出确认,确认后执行)
adminSetAgentStatus API 调用 PUT /admin/agents/{name}/status
AgentStats.status 字段加入前端类型定义
同步部署:前端产物已构建并覆盖 gateway/internal/static/static/
go test 全绿,tsc --noEmit 通过
This commit is contained in:
@ -397,12 +397,28 @@ export interface AgentStats {
|
||||
sent_total: number;
|
||||
/** 参与的未归档会话数,配合默认值判断设多少合适 */
|
||||
active_sessions: number;
|
||||
/** online / offline / disabled —— 管理页靠它区分状态并决定显示停用/恢复按钮 */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function adminListAgentStats() {
|
||||
return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas');
|
||||
}
|
||||
|
||||
/** 停用(true)或恢复(false)一个 Agent。返回撤销的密钥数等信息。 */
|
||||
export async function adminSetAgentStatus(agentName: string, disabled: boolean) {
|
||||
return request<{
|
||||
agent_name: string;
|
||||
disabled: boolean;
|
||||
keys_revoked?: number;
|
||||
detail: string;
|
||||
}>(
|
||||
'PUT',
|
||||
`/admin/agents/${encodeURIComponent(agentName)}/status`,
|
||||
{ disabled }
|
||||
);
|
||||
}
|
||||
|
||||
/** 改该 Agent 的新任务默认预算(0 = 不限)。 */
|
||||
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
|
||||
return request<{ quota: AgentStats }>(
|
||||
|
||||
@ -1,24 +1,19 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { BotIcon, CheckIcon } from './icons';
|
||||
import { BotIcon, CheckIcon, CloseIcon } from './icons';
|
||||
|
||||
/**
|
||||
* Agent 新任务默认预算(管理员)。
|
||||
* Agent 管理面板(管理员):默认预算 + 停用/恢复。
|
||||
*
|
||||
* **这里配的是默认值,不是额度。**
|
||||
*
|
||||
* 额度(往返预算)属于具体任务 —— 在写信时给、在对话页里随时改。
|
||||
* 这个面板只决定「派给某个 Agent 的新任务,如果没人显式指定,默认给几个来回」:
|
||||
* 跑测试的小工具与重构整个模块的 Agent,合理来回数差一个量级,所以分开设。
|
||||
*
|
||||
* 累计发信数只是观测数据,不拦任何请求 —— 之前它是「终身额度」,
|
||||
* 但终身额度跑满要人工重置才能再干活,而 Agent 是长期在线的。
|
||||
* 预算决定「派给某个 Agent 的新任务默认多少个来回」。
|
||||
* 停用/恢复控制 Agent 的准入:停用后密钥撤销、注册被拒,邮件与会话保留。
|
||||
*/
|
||||
export default function QuotaPanel() {
|
||||
const [stats, setStats] = useState<api.AgentStats[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@ -52,11 +47,40 @@ export default function QuotaPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (name: string, currentDisabled: boolean) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.adminSetAgentStatus(name, !currentDisabled);
|
||||
await load();
|
||||
setConfirming(null);
|
||||
// 停用时显示撤销了几把密钥,这是操作结果里最有信息量的部分
|
||||
if (result.detail) setError(null); // 清掉旧错误,让 detail 独占提示区
|
||||
setBusy(null);
|
||||
return;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (s: api.AgentStats) => {
|
||||
const st = s.status ?? 'offline';
|
||||
const cls = st === 'online'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: st === 'disabled'
|
||||
? 'bg-red-50 text-red-600'
|
||||
: 'bg-gray-50 text-gray-500';
|
||||
const label = st === 'online' ? '在线' : st === 'disabled' ? '已停用' : '离线';
|
||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${cls}`}>{label}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BotIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent 新任务默认预算</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent 管理</h3>
|
||||
<span className="text-xs text-gray-400">{stats.length}</span>
|
||||
</div>
|
||||
|
||||
@ -67,7 +91,7 @@ export default function QuotaPanel() {
|
||||
插件自动转发的最终总结与权限询问不占用预算。
|
||||
</p>
|
||||
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
{error && <div className="text-[11px] text-gray-600 bg-gray-50 rounded px-2 py-1.5">{error}</div>}
|
||||
|
||||
{stats.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||||
@ -77,12 +101,17 @@ export default function QuotaPanel() {
|
||||
const draft = drafts[s.agent_name] ?? String(s.default_rounds);
|
||||
const dirty = draft !== String(s.default_rounds);
|
||||
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||||
const isDisabled = s.status === 'disabled';
|
||||
const isConfirming = confirming === s.agent_name;
|
||||
|
||||
return (
|
||||
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
|
||||
{s.agent_name}
|
||||
</span>
|
||||
|
||||
{statusBadge(s)}
|
||||
|
||||
<div className="min-w-0 flex-1 text-[11px] text-gray-500">
|
||||
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
|
||||
<span className="text-gray-400">
|
||||
@ -107,6 +136,39 @@ export default function QuotaPanel() {
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 停用/恢复按钮 */}
|
||||
{isConfirming ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-gray-500">确认?</span>
|
||||
<button
|
||||
onClick={() => toggleStatus(s.agent_name, isDisabled)}
|
||||
disabled={busy === s.agent_name}
|
||||
className="tap text-[10px] px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100"
|
||||
>
|
||||
{isDisabled ? '恢复' : '停用'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(null)}
|
||||
className="tap text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirming(s.agent_name)}
|
||||
disabled={busy === s.agent_name}
|
||||
title={isDisabled ? '恢复此 Agent(密钥需重新签发)' : '停用此 Agent(撤销全部密钥,可恢复)'}
|
||||
className={`tap shrink-0 ${
|
||||
isDisabled
|
||||
? 'text-gray-400 hover:text-green-600'
|
||||
: 'text-gray-400 hover:text-red-500'
|
||||
} disabled:opacity-30`}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user