Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/api_plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Shared>, Path(id): Path<i64>) -> 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 由前端显示错误卡片。
Expand Down Expand Up @@ -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<u8> {
let manifest = format!(
Expand Down
5 changes: 4 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
14 changes: 14 additions & 0 deletions src/plugin/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 的调用
/// 链是同步的,不等插件。
///
Expand Down
4 changes: 3 additions & 1 deletion web-admin/scripts/cls-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 单点登录", "应急密码"] },
Expand Down
44 changes: 43 additions & 1 deletion web-admin/src/components/Plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -260,6 +260,8 @@ function PluginLogsCard({ plugins, pulse }: { plugins: Plugin[]; pulse: number }
const [page, setPage] = useState(1)
const [events, setEvents] = useState<string[]>(Object.keys(EVENT_BADGES))
const [tick, setTick] = useState(0)
const [clearing, setClearing] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
// 序号化的重拉:翻页、刷新与动作后的并发响应若乱序,只有最新一次落地——
// 与父组件 loadSeq 同一招,旧页的慢响应不能盖掉新页。
const seq = useRef(0)
Expand Down Expand Up @@ -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) =>
Expand All @@ -355,6 +378,15 @@ function PluginLogsCard({ plugins, pulse }: { plugins: Plugin[]; pulse: number }
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={clearing}
onClick={() => setConfirmClear(true)}
title="清空派发日志"
>
<Trash2 className="size-4" /> 清空
</Button>
<Button variant="outline" size="icon" title="刷新" aria-label="刷新日志" onClick={() => setTick((n) => n + 1)}>
<RefreshCw />
</Button>
Expand Down Expand Up @@ -440,6 +472,16 @@ function PluginLogsCard({ plugins, pulse }: { plugins: Plugin[]; pulse: number }
</Button>
</div>
)}
{confirmClear && (
<ConfirmDialog
title={`清空「${plugins.find((p) => p.id === selected)?.name ?? "此插件"}」的派发日志?`}
description="将清空这个插件的所有派发记录,操作不可撤销。缓冲是 hub 进程内的环形,重启之后也是空。"
confirmLabel="清空"
busy={clearing}
onClose={() => setConfirmClear(false)}
onConfirm={clearLogs}
/>
)}
</Card>
)
}
Expand Down
10 changes: 10 additions & 0 deletions web-admin/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,16 @@ export const testPlugin = (id: number) =>
export const pluginLogs = (id: number, page: number, pageSize: number) =>
api<PluginLogPage>(`/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)}`, {
Expand Down
Loading