diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e8644e..e2ae600b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to monitor-hub will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- 面板「插件」页派发日志卡片新增「清空」按钮:选中一个插件后点清空,弹确认框,再点确认即把该插件在 hub 内存环形缓冲里的全部派发记录一次性丢掉。响应体带 `cleared` 计数,面板 toast 据此告诉操作员清掉了多少;空管道也走同一条路返回 200 + `cleared:0`,不把「明明没有记录」包装成失败。按钮复用 GET 同名路由的鉴权面(Admin 提取器)与 404 门,不存在的插件行号 404。 + ## [2.2.0] - 2026-09-22 ### Added diff --git a/src/api_plugins.rs b/src/api_plugins.rs index 2cf0df89..d72c96a8 100644 --- a/src/api_plugins.rs +++ b/src/api_plugins.rs @@ -834,6 +834,25 @@ pub async fn plugin_dispatch_log( .into_response() } +/// 清空一个插件的派发日志(R16):面板给操作员的逃生门,缓冲是内存里的环形, +/// 清掉就是丢。返回实际清掉的条数,面板 toast 据此告诉操作员清掉了多少—— +/// 「丢了什么」是这种不可撤销动作值得多报一句话的那类。0 行走同一条路, +/// not 空管道不该把「明明没有记录」包装成失败。 +/// +/// 复用 GET 的鉴权面(Admin 提取器)与 404 门(plugin_or_404):未登录与不存 +/// 在的插件行号,操作员的预期与「查看」一致——不必为「清空」再细一档, +/// 反而引入新的边界差异。push 与 `Registry::clear_dispatch_log` 共用一把 +/// 互斥锁,清的过程中并发派发照常写,新条目都在锁释放之后落地,不存在「 +/// 边清边写丢一条」的窗口。 +pub async fn clear_plugin_dispatch_log(_: Admin, State(app): State, Path(id): Path) -> Response { + let plugin_id = match plugin_or_404(&app, id) { + Ok(plugin_id) => plugin_id, + Err(resp) => return resp, + }; + let cleared = app.plugins.write().unwrap_or_else(|e| e.into_inner()).clear_dispatch_log(&plugin_id); + Json(json!({"cleared": cleared})).into_response() +} + /// 一个声明了 page 的启用插件渲染它的面板页面(U5/KTD5)。宿主调插件的 /// `render_page` 导出,把返回的 JSON UI 描述原样转给前端;插件侧失败 /// (超时/trap/非 2xx)返回 502 由前端显示错误卡片。 @@ -1762,6 +1781,37 @@ mod tests { assert_eq!(test_plugin(Admin, State(app.clone()), Path(9999)).await.status(), StatusCode::NOT_FOUND); } + /// 清空一个插件的派发日志:首次清空返回实际清掉的条数,缓冲立刻空; + /// 空管道再清空走同一条路返回 0,不报错;不存在的行号 404,与查看同门。 + /// 启用即 tick 落一条 entry 后,清空前快照非空、清空后快照空;`until_tick` + /// 不再用(它只判断是否非空,清空之后首次轮询就提前返回),所以这条路径 + /// 不重复触发 tick,而是直接断言终态。 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn deleting_a_plugins_dispatch_log_emits_count_and_clears() { + let app = plugin_app(); + assert_eq!(upload(&app, tick_archive("com.example.clearable")).await.status(), StatusCode::OK); + let id = app.db.list_plugins().unwrap()[0].id; + assert_eq!(enable_plugin(Admin, State(app.clone()), Path(id)).await.status(), StatusCode::OK); + until_tick(&app).await; + assert_eq!(app.plugins.read().unwrap_or_else(|e| e.into_inner()).dispatch_log_snapshot().len(), 1); + + // 首次清空:cleared=1,缓冲空。 + let body = body_of(clear_plugin_dispatch_log(Admin, State(app.clone()), Path(id)).await).await; + assert_eq!(body["cleared"], 1); + assert_eq!(app.plugins.read().unwrap_or_else(|e| e.into_inner()).dispatch_log_snapshot().len(), 0); + + // 空管道再清空:cleared=0,200,与「没有失败原因」分得开。 + let empty = clear_plugin_dispatch_log(Admin, State(app.clone()), Path(id)).await; + assert_eq!(empty.status(), StatusCode::OK); + assert_eq!(body_of(empty).await["cleared"], 0); + + // 不存在的行号:404,与查看路径同门(plugin_or_404)。 + assert_eq!( + clear_plugin_dispatch_log(Admin, State(app.clone()), Path(9999)).await.status(), + StatusCode::NOT_FOUND + ); + } + /// 声明 `tick` 的 manifest 加一个会写 kv 的 on_tick 模块。 fn tick_archive(plugin_id: &str) -> Vec { let manifest = format!( diff --git a/src/main.rs b/src/main.rs index c8ee9044..63b8c461 100644 --- a/src/main.rs +++ b/src/main.rs @@ -461,7 +461,10 @@ async fn main() -> Result<()> { .route("/api/plugins/{id}/enable", post(api_plugins::enable_plugin)) .route("/api/plugins/{id}/disable", post(api_plugins::disable_plugin)) .route("/api/plugins/{id}/test", post(api_plugins::test_plugin)) - .route("/api/plugins/{id}/logs", get(api_plugins::plugin_dispatch_log)) + .route( + "/api/plugins/{id}/logs", + get(api_plugins::plugin_dispatch_log).delete(api_plugins::clear_plugin_dispatch_log), + ) .route("/api/plugins/{id}/kv", get(api_plugins::list_plugin_kv)) .route("/api/plugins/{id}/page", get(api_plugins::render_plugin_page)) .route("/api/plugins/{id}/action", post(api_plugins::plugin_page_action)) diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 8f64fbb2..1cee7256 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -186,6 +186,20 @@ impl Registry { self.dispatch_log.lock().unwrap_or_else(|e| e.into_inner()).iter().rev().cloned().collect() } + /// 清空一个插件的派发记录(R16):面板给操作员的逃生门。缓冲是内存里的环形, + /// 清掉就是丢;长期审计在通知日志表,那里没有「清空」入口。 + /// + /// 返回实际清掉的条数,面板 toast 据此告诉操作员清掉了多少。0 行也走这条 + /// 路——not 空管道不该把"明明没有记录"包装成失败。push 与本方法共用同 + /// 把互斥锁,清的过程中派发照常写,但都排在锁后;新条目不会同时被丢, + /// 因为它们进队列的时机就在锁释放之后。 + pub fn clear_dispatch_log(&self, plugin_id: &str) -> usize { + let mut log = self.dispatch_log.lock().unwrap_or_else(|e| e.into_inner()); + let before = log.len(); + log.retain(|entry| entry.plugin_id != plugin_id); + before - log.len() + } + /// 派发到所有订阅该事件的已启用插件(R5),fire-and-forget:emit 的调用 /// 链是同步的,不等插件。 /// diff --git a/web-admin/scripts/cls-budget.mjs b/web-admin/scripts/cls-budget.mjs index 926823c6..6639ae48 100644 --- a/web-admin/scripts/cls-budget.mjs +++ b/web-admin/scripts/cls-budget.mjs @@ -108,10 +108,12 @@ const PREVIEW = /^\/api\/themes\/[^/]+\/preview$/ // markers 全部是「数据到齐才画出来」的文字。用数据本身而不是固定文案,是为了让 // 请求失败(catch 分支渲染空态)也满足不了断言——那正是假通过的高发处。 +// 插件页的「清空」来自派发日志卡片的按钮——按钮没渲染就不可能命中,免得 +// 「按钮从 DOM 里消失」也一路绿灯。 const ROUTES = [ { path: "/admin/nodes", markers: ["hk-1", "jp-1"] }, { path: "/admin/ping", markers: ["东京-延迟"] }, - { path: "/admin/plugins", markers: ["示例插件"] }, + { path: "/admin/plugins", markers: ["示例插件", "清空"] }, { path: "/admin/data", markers: ["可回收空间", "保留天数"] }, { path: "/admin/themes", markers: ["默认主题"] }, { path: "/admin/security", markers: ["登录会话", "GitHub 单点登录", "应急密码"] }, diff --git a/web-admin/src/components/Plugins.tsx b/web-admin/src/components/Plugins.tsx index 6d3a13af..11ae04aa 100644 --- a/web-admin/src/components/Plugins.tsx +++ b/web-admin/src/components/Plugins.tsx @@ -12,7 +12,7 @@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Textarea } from "@/components/ui/textarea" import { - deletePlugin, deletePluginKv, disablePlugin, enablePlugin, exportPluginUrl, kvDeletable, kvIsDefault, kvShownValue, kvWriteFor, listPluginKv, listPlugins, pluginCleanup, pluginLogs, setPluginKv, testPlugin, uploadPlugin, + deletePlugin, deletePluginKv, disablePlugin, enablePlugin, exportPluginUrl, kvDeletable, kvIsDefault, kvShownValue, kvWriteFor, listPluginKv, listPlugins, pluginCleanup, pluginLogs, clearPluginLogs, setPluginKv, testPlugin, uploadPlugin, type KvDraft, type Plugin, type PluginLogEntry, } from "@/lib/api" import { testResultsText } from "@/lib/format" @@ -260,6 +260,8 @@ function PluginLogsCard({ plugins, pulse }: { plugins: Plugin[]; pulse: number } const [page, setPage] = useState(1) const [events, setEvents] = useState(Object.keys(EVENT_BADGES)) const [tick, setTick] = useState(0) + const [clearing, setClearing] = useState(false) + const [confirmClear, setConfirmClear] = useState(false) // 序号化的重拉:翻页、刷新与动作后的并发响应若乱序,只有最新一次落地—— // 与父组件 loadSeq 同一招,旧页的慢响应不能盖掉新页。 const seq = useRef(0) @@ -330,6 +332,27 @@ function PluginLogsCard({ plugins, pulse }: { plugins: Plugin[]; pulse: number } // eslint-disable-next-line react-hooks/exhaustive-deps }, [epoch, page]) + // 清空派发日志(R16):缓冲是 hub 内存里的环形,清掉就是丢。响应带 + // `cleared` 计数,toast 据此告诉操作员清掉了多少——「丢了什么」是这种 + // 不可撤销动作值得多报一句话的那类。空管道也走同一条路(200, cleared=0), + // toast 落到「派发日志已是空的」,与真有记录被清走的两条 toast 分开。 + async function clearLogs() { + if (selected == null) return + setClearing(true) + try { + const { cleared } = await clearPluginLogs(selected) + toast.success(cleared ? `已清空 ${cleared} 条记录` : "派发日志已是空的") + // 调本地的 epoch(tick++)重拉,父组件的 pulse 只在「测试」/启停等动作后才 + // 递增,清空是当前组件自己的事,不该动别人的视图。 + setTick((n) => n + 1) + } catch (e) { + toast.error((e as Error).message) + } finally { + setClearing(false) + setConfirmClear(false) + } + } + if (!plugins.length || selected == null) return null const shown = (entries ?? []).filter((entry) => events.includes(entry.event_type)) const toggle = (type: string) => @@ -355,6 +378,15 @@ function PluginLogsCard({ plugins, pulse }: { plugins: Plugin[]; pulse: number } ))} + @@ -440,6 +472,16 @@ function PluginLogsCard({ plugins, pulse }: { plugins: Plugin[]; pulse: number } )} + {confirmClear && ( + p.id === selected)?.name ?? "此插件"}」的派发日志?`} + description="将清空这个插件的所有派发记录,操作不可撤销。缓冲是 hub 进程内的环形,重启之后也是空。" + confirmLabel="清空" + busy={clearing} + onClose={() => setConfirmClear(false)} + onConfirm={clearLogs} + /> + )} ) } diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 0dd49aff..43d67654 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -439,6 +439,16 @@ export const testPlugin = (id: number) => export const pluginLogs = (id: number, page: number, pageSize: number) => api(`/plugins/${id}/logs?page=${page}&page_size=${pageSize}`) +/** + * 清空一个插件的派发日志(R16):面板给操作员的逃生门。响应体带 `cleared`, + * 面板 toast 据此告诉操作员清掉了多少——「丢了什么」是这种不可撤销动作 + * 值得多报一句话的那类。空管道也走同一条路(200, cleared=0),不该把「明明 + * 没有记录」包装成失败——`httpErrorText` 对空体响应回退到状态码,不会让 + * toast 出现空白。 + */ +export const clearPluginLogs = (id: number) => + api<{ cleared: number }>(`/plugins/${id}/logs`, { method: "DELETE" }) + /** 写一个插件的 kv 行(R13)。key 校验在后端 set_plugin_kv。 */ export const setPluginKv = (id: number, key: string, value: string) => api<{ ok: boolean }>(`/plugins/${id}/kv/${encodeURIComponent(key)}`, {