diff --git a/config/flap_test.go b/config/flap_test.go index 9b8d106..d493484 100644 --- a/config/flap_test.go +++ b/config/flap_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "testing" "time" @@ -61,3 +62,79 @@ func TestNodeFlapTolerantWithoutConfig(t *testing.T) { t.Errorf("node flap tolerance from an empty file = %v, want 0", got) } } + +// nodeEntry reads one node's raw object back from the settings file, the way +// GetSettings serves it to the console. +func nodeEntry(t *testing.T, uuid string) map[string]interface{} { + t.Helper() + data, err := GetSettings(SettingBotNodeConfig) + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + var all map[string]map[string]interface{} + if err := json.Unmarshal(data, &all); err != nil { + t.Fatalf("unmarshal settings: %v", err) + } + return all[uuid] +} + +// TestFlapTolerantSettingsRoundTrip drives the payload the console's node card +// actually posts — { "": { "flap_tolerant": } } — through the +// settings API, so the frontend and the accessor are checked against each other +// and not just against hand-written files. +func TestFlapTolerantSettingsRoundTrip(t *testing.T) { + writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{ + "node-a": { "enableStatusNotify": false } +}`) + if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil { + t.Fatalf("LoadBotNodeConfig: %v", err) + } + + patch := map[string]interface{}{ + "node-a": map[string]interface{}{"flap_tolerant": 30}, + } + if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + if got := NodeFlapTolerant("node-a"); got != 30*time.Second { + t.Errorf("NodeFlapTolerant after save = %v, want 30s", got) + } + // The sibling option must survive: the card saves only the field it edits. + entry := nodeEntry(t, "node-a") + if entry["enableStatusNotify"] != false { + t.Errorf("enableStatusNotify = %v, want it preserved as false", entry["enableStatusNotify"]) + } + if entry["flap_tolerant"] != float64(30) { + t.Errorf("flap_tolerant on disk = %v, want 30", entry["flap_tolerant"]) + } +} + +// TestFlapTolerantSettingsRemoval: clearing the console's field sends a JSON +// null, which must remove the key rather than leave an explicit 0 behind. +func TestFlapTolerantSettingsRemoval(t *testing.T) { + writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{ + "node-a": { "enableStatusNotify": true, "flap_tolerant": 30 } +}`) + if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil { + t.Fatalf("LoadBotNodeConfig: %v", err) + } + + patch := map[string]interface{}{ + "node-a": map[string]interface{}{"flap_tolerant": nil}, + } + if err := UpdateSettings(SettingBotNodeConfig, patch); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + if got := NodeFlapTolerant("node-a"); got != 0 { + t.Errorf("NodeFlapTolerant after removal = %v, want 0", got) + } + entry := nodeEntry(t, "node-a") + if _, present := entry["flap_tolerant"]; present { + t.Error("flap_tolerant is still in the file after being cleared") + } + if entry["enableStatusNotify"] != true { + t.Errorf("enableStatusNotify = %v, want it preserved as true", entry["enableStatusNotify"]) + } +} diff --git a/frontend/README.md b/frontend/README.md index 513277e..f7380c8 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -5,7 +5,7 @@ Web console for the Nukumizu backend — a remote-server monitor and alert bot. ## Features - **Sign in / first-run admin** — login, or create the very first admin while the database is still empty (the backend rejects further registrations once any user exists). -- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load, plus the per-node *status notify* switch persisted to `bot_node_config.json`. Searchable, auto-refreshes. +- **Nodes (overview)** — live server cards from the backend status/info APIs: online state, CPU / RAM / disk load. The pencil on a card opens a per-node options dialog holding the *status notify* switch and the *flap inhibition* window, persisted to `bot_node_config.json`. Searchable, auto-refreshes. - **Bot trust** — manage QQ (NapCat) and Telegram admins & trusted groups in `bot_user_config.json`: add / remove members and toggle each member's `status notify`, `startup notify`, and `command replies`. - **Settings** — structured forms for the global `config.json` (system, debug, Komari dashboard, controller methods, message templates, storage paths). Each card saves only its own section. - **System logs** — live WebSocket log stream (`/api/system/getLogs`) with severity filter chips, search, pause/resume and export. diff --git a/frontend/src/views/Overview.vue b/frontend/src/views/Overview.vue index d5d74d8..8cffc84 100644 --- a/frontend/src/views/Overview.vue +++ b/frontend/src/views/Overview.vue @@ -4,6 +4,7 @@ import { serverApi, settingsApi } from '../api/index.js'; import { toast } from '../utils/toast.js'; import { formatBytes, shortUUID } from '../utils/fmt.js'; import Toggle from '../components/Toggle.vue'; +import Modal from '../components/Modal.vue'; function percent(m) { return m && m.total ? Math.min(100, Math.max(0, Math.round((m.used / m.total) * 100))) : 0; @@ -13,9 +14,20 @@ const query = ref(''); const loading = ref(true); const failed = ref(false); const entries = ref([]); -const saving = reactive(new Set()); let timer = null; +// The per-node options dialog. Its form is a copy of the node's settings taken +// when the dialog opens, so the 15s card refresh behind it cannot disturb an +// open editor, and closing without saving leaves the node untouched. +const editing = reactive({ + open: false, + uuid: '', + name: '', + notify: true, + flap: '', + saving: false +}); + const filtered = computed(() => { const q = query.value.trim().toLowerCase(); if (!q) return entries.value; @@ -57,7 +69,8 @@ async function load() { online: !!s.online, report: s.report || null, info: infoEntry.info || null, - notify: conf.enableStatusNotify !== false + notify: conf.enableStatusNotify !== false, + flapTolerant: Number(conf.flap_tolerant) > 0 ? Number(conf.flap_tolerant) : 0 }); } list.sort((a, b) => (a.online === b.online ? a.name.localeCompare(b.name) : b.online ? 1 : -1)); @@ -71,21 +84,42 @@ async function load() { } } -async function toggleNotify(entry) { - const next = !entry.notify; - const prev = entry.notify; - entry.notify = next; - saving.add(entry.uuid); +function openOptions(entry) { + editing.uuid = entry.uuid; + editing.name = entry.name; + editing.notify = entry.notify; + editing.flap = entry.flapTolerant ? String(entry.flapTolerant) : ''; + editing.open = true; +} + +// saveOptions writes both options in one patch. A flap window that is not a +// positive number disables inhibition, which is sent as a JSON null so the key +// is removed rather than stored as an explicit 0 — the file then keeps matching +// its documented "absent means off" default. +async function saveOptions() { + const typed = Number(editing.flap); + const seconds = Number.isFinite(typed) && typed > 0 ? typed : 0; + const uuid = editing.uuid; + + editing.saving = true; try { const patch = {}; - patch[entry.uuid] = { enableStatusNotify: next }; + patch[uuid] = { + enableStatusNotify: editing.notify, + flap_tolerant: seconds > 0 ? seconds : null + }; await settingsApi.set('bot_node_config', patch); - toast.success(`${entry.name}: status notify ${next ? 'on' : 'off'}`); + const entry = entries.value.find((e) => e.uuid === uuid); + if (entry) { + entry.notify = editing.notify; + entry.flapTolerant = seconds; + } + toast.success(`${editing.name}: options saved`); + editing.open = false; } catch (e) { - entry.notify = prev; toast.error('Save failed: ' + e.message); } finally { - saving.delete(entry.uuid); + editing.saving = false; } } @@ -111,7 +145,7 @@ onBeforeUnmount(() => {

Overview

Nodes

-

Monitored servers with live load, online state, and the per-node status-notify switch.

+

Monitored servers with live load and online state. The pencil on a card opens that node's status-notify switch and flap-inhibition window.

-
- +
+ + Notify off + + + Flap {{ e.flapTolerant }}s + +
+ + +
+

{{ editing.name }}

+

{{ editing.uuid }}

+
+ +
+ + +

With this off, the node's status changes are not delivered to any controller — chat bots and notification pipes alike.

+
+ +
+ +
+ + seconds +
+

How long the node may drop offline and come back without a notification being sent. An outage lasting longer than this is still reported. Leave empty or 0 to report every change right away.

+
+ + +
@@ -298,7 +375,67 @@ onBeforeUnmount(() => { flex-wrap: wrap; } -.notify { display: flex; align-items: center; } +.opts { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + /* Stay right-aligned even when the spec block is absent. */ + margin-left: auto; +} + +.opts .chip { white-space: nowrap; } + +/* --- Node options dialog ------------------------------------------------ */ + +.node-head { + padding-bottom: 14px; + margin-bottom: 16px; + border-bottom: 1px solid var(--line); +} + +.node-name { + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +.node-uuid { + font-size: 11.5px; + color: var(--text-3); + margin-top: 3px; + overflow-wrap: anywhere; +} + +/* The modal body has no grid to space stacked fields. */ +.field + .field { margin-top: 20px; } + +.flap-row { + display: flex; + align-items: center; + gap: 9px; +} + +.flap-input { + width: 110px; + text-align: center; + font-variant-numeric: tabular-nums; + /* Match the plain hand-rolled field look: the rest of the console renders + numbers as ordinary inputs, so the spinner controls would stand out. */ + -moz-appearance: textfield; + appearance: textfield; +} + +.flap-input::-webkit-outer-spin-button, +.flap-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.flap-unit { + font-size: 12.5px; + color: var(--text-3); +} .refresh-hint { font-size: 12px; diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index c06a1cd..010d3e8 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -23,7 +23,7 @@ const sections = [ { id: 'debug', title: 'Debug logging', - hint: 'Per-module verbosity for the bot pipes.', + hint: 'Per-module verbosity for the bot pipes and node status notifications.', root: ['debug'], fields: [ { key: 'showOneBotMsg', type: 'bool', label: 'OneBot messages' }, @@ -31,6 +31,7 @@ const sections = [ { key: 'showTelegramMsg', type: 'bool', label: 'Telegram messages' }, { key: 'showTriggerCmdEcho', type: 'bool', label: 'Trigger command echo' }, { key: 'showKomariTaskEcho', type: 'bool', label: 'Komari task echo' }, + { key: 'showFlapTolerantEcho', type: 'bool', label: 'Flap inhibition echo', help: 'Log every flap-inhibition decision: an offline change being held, released by a quick return, or reported after the window elapsed.' }, { key: 'onebotIgnoreSelfMsg', type: 'bool', label: 'Ignore OneBot self messages' } ] }, diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 36119c3..93c1990 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -372,11 +372,21 @@ func (m *Manager) holdOffline(change node.StatusChange, tolerance time.Duration) m.flapPending[change.UUID] = held m.flapMu.Unlock() - if config.Current().Debug.ShowFlapTolerantEcho { + if flapEchoEnabled() { postLog.Debug(fmt.Sprintf("Offline for node %s held for %s (flap tolerance)", change.UUID, tolerance)) } } +// flapEchoEnabled reports whether the flap-inhibition log lines are emitted, +// per the ShowFlapTolerantEcho debug flag. config.Current is nil before the +// first configuration load — which includes every unit test that drives the +// manager directly — so it is checked before the flag is read, the way the +// other per-module debug flags are (see internal/controller/pipes/qq_onebot). +func flapEchoEnabled() bool { + cfg := config.Current() + return cfg != nil && cfg.Debug.ShowFlapTolerantEcho +} + // fireHeldOffline dispatches a held Offline once its window has elapsed. It // claims the entry first and returns without sending if the hold was already // released or superseded, so a node that came back in time never reports an @@ -394,13 +404,13 @@ func (m *Manager) fireHeldOffline(uuid string, held *flapPending) { // Re-check the allow-list: it may have been edited while the change was // held, and years-long holds are possible in principle. if !config.NodeStatusNotifyEnabled(uuid) { - if config.Current().Debug.ShowFlapTolerantEcho { + if flapEchoEnabled() { postLog.Debug(fmt.Sprintf("Held status change for node %s dropped: enableStatusNotify is not enabled", uuid)) } return } - if config.Current().Debug.ShowFlapTolerantEcho { + if flapEchoEnabled() { postLog.Debug(fmt.Sprintf("Node %s still offline after flap tolerance; reporting it now", uuid)) } m.dispatchStatusChange(held.change) @@ -430,7 +440,7 @@ func (m *Manager) releaseHeldOffline(uuid string) bool { held.timer.Stop() delete(m.flapPending, uuid) - if config.Current().Debug.ShowFlapTolerantEcho { + if flapEchoEnabled() { postLog.Debug(fmt.Sprintf("Node %s returned within flap tolerance; suppressing both halves", uuid)) } return true