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;
|
sent_total: number;
|
||||||
/** 参与的未归档会话数,配合默认值判断设多少合适 */
|
/** 参与的未归档会话数,配合默认值判断设多少合适 */
|
||||||
active_sessions: number;
|
active_sessions: number;
|
||||||
|
/** online / offline / disabled —— 管理页靠它区分状态并决定显示停用/恢复按钮 */
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adminListAgentStats() {
|
export async function adminListAgentStats() {
|
||||||
return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas');
|
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 = 不限)。 */
|
/** 改该 Agent 的新任务默认预算(0 = 不限)。 */
|
||||||
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
|
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
|
||||||
return request<{ quota: AgentStats }>(
|
return request<{ quota: AgentStats }>(
|
||||||
|
|||||||
@ -1,24 +1,19 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import * as api from '../api/client';
|
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() {
|
export default function QuotaPanel() {
|
||||||
const [stats, setStats] = useState<api.AgentStats[]>([]);
|
const [stats, setStats] = useState<api.AgentStats[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [confirming, setConfirming] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BotIcon className="w-4 h-4 text-gray-500" />
|
<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>
|
<span className="text-xs text-gray-400">{stats.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -67,7 +91,7 @@ export default function QuotaPanel() {
|
|||||||
插件自动转发的最终总结与权限询问不占用预算。
|
插件自动转发的最终总结与权限询问不占用预算。
|
||||||
</p>
|
</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 ? (
|
{stats.length === 0 ? (
|
||||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
<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 draft = drafts[s.agent_name] ?? String(s.default_rounds);
|
||||||
const dirty = draft !== String(s.default_rounds);
|
const dirty = draft !== String(s.default_rounds);
|
||||||
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||||||
|
const isDisabled = s.status === 'disabled';
|
||||||
|
const isConfirming = confirming === s.agent_name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
<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">
|
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
|
||||||
{s.agent_name}
|
{s.agent_name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{statusBadge(s)}
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 text-[11px] text-gray-500">
|
<div className="min-w-0 flex-1 text-[11px] text-gray-500">
|
||||||
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
|
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
|
||||||
<span className="text-gray-400">
|
<span className="text-gray-400">
|
||||||
@ -107,6 +136,39 @@ export default function QuotaPanel() {
|
|||||||
>
|
>
|
||||||
<CheckIcon className="w-4 h-4" />
|
<CheckIcon className="w-4 h-4" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user