From 34a686fca6dda19d6855d2255a89f5676f46e89f Mon Sep 17 00:00:00 2001
From: BenCodez <17074231+BenCodez@users.noreply.github.com>
Date: Mon, 31 Aug 2026 17:57:57 -0600
Subject: [PATCH 01/57] Redesign the Control dashboard shell
---
README.md | 5 +
docs/control-management.md | 2 +
src/main/resources/web/app.css | 95 ++-
src/main/resources/web/app.js | 554 +++++++++++++++++-
src/main/resources/web/index.html | 106 +++-
.../control/http/ControlHttpServerTest.java | 10 +-
6 files changed, 709 insertions(+), 63 deletions(-)
diff --git a/README.md b/README.md
index bfc51a99..945f11c7 100644
--- a/README.md
+++ b/README.md
@@ -250,6 +250,11 @@ rollback workflow still applies; redaction placeholders preserve each target's c
### WebUI management suite
+- **Overview** uses a persistent grouped sidebar and server-aware header to keep the selected target visible. Its six
+ bounded summary cards, Attention Required feed, topology, logged-service activity, recent operations, and capability-
+ gated quick actions reuse existing inspections and management flows. Health reflects only currently observed Control,
+ topology, VoteSite, communication, operation, and VoteLog state: a disconnected registered node is a warning, unknown
+ Minecraft presence is not called offline, and vote counts remain explicitly labeled as logged `VOTE_RECEIVED` events.
- **Setup** provides a readiness checklist, browser-local non-secret profiles, dedicated auto-create and VoteLogging cards,
the existing typed setup assistant, a reward builder with simulation plus preview/apply, and a searchable common-setting
catalog.
diff --git a/docs/control-management.md b/docs/control-management.md
index 2df6c290..375551ba 100644
--- a/docs/control-management.md
+++ b/docs/control-management.md
@@ -56,6 +56,8 @@ authenticated, CSRF-protected endpoint and node capability checks as an external
| Area | What the WebUI does | Safety/accuracy boundary |
| --- | --- | --- |
+| Navigation and context | Keeps the selected VotingPlugin node visible in the persistent header, groups existing tools in a desktop sidebar/mobile drawer, and provides bounded search shortcuts for pages, settings, and registered nodes | Search does not enumerate players; shortcut pages reuse the existing capability-gated panels; server changes keep the normal stale-result and unsaved-draft protections |
+| Overview dashboard | Builds six summary cards, Attention Required, quick actions, topology, logged-service activity, and recent operation activity from the node registry plus existing `overview`, `vote-site-health`, and `vote-log-summary` inspections | Read-only aggregation; disconnected registered nodes produce a warning, unknown Minecraft presence is not treated as offline, VoteLog counts are labeled as logged events, and actions only open existing safe workflows rather than inventing auto-fixes |
| Network Doctor | Runs `diagnostics` (which includes the overview fields), combines node health with Control's current topology, and displays checks for connector, configuration, Votifier, vote sites, rewards, logging, and proxy topology | Read-only; “healthy” is bounded reported state, not a synthetic vote |
| Diagnostics download | Downloads the last Network Doctor result as local JSON | Redacted status bundle only; no raw configuration/logs/player records/infrastructure secrets |
| Activity | Loads the newest 50 live/recovered operation views, labels phases, lineage, reload/rollback, resumes eligible guided preview approvals, and offers retry only when `retryable` | Recovered history cannot be retried; approval is single-use and apply is CSRF-protected; proxy-method apply needs a new preview |
diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css
index 3c0244e5..bee4307f 100644
--- a/src/main/resources/web/app.css
+++ b/src/main/resources/web/app.css
@@ -28,19 +28,26 @@ button:disabled { opacity: .46; cursor: not-allowed; }
button.secondary { border-color: var(--border-strong); background: transparent; color: #d6e5f7; }
button.secondary:hover:not(:disabled) { background: var(--surface-soft); border-color: #53769d; }
button.compact { padding: 9px 12px; }
+button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible {
+ outline: 2px solid #8cc4ff;
+ outline-offset: 2px;
+}
-.topbar { position: sticky; z-index: 10; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 76px; padding: 12px max(24px, calc((100vw - 1240px) / 2)); border-bottom: 1px solid rgba(54, 84, 117, .72); background: rgba(7, 16, 29, .9); backdrop-filter: blur(18px); }
+.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 76px; padding: 12px max(24px, calc((100vw - 1480px) / 2)); border-bottom: 1px solid rgba(54, 84, 117, .72); background: rgba(7, 16, 29, .94); backdrop-filter: blur(18px); }
.brand { display: flex; align-items: center; gap: 11px; color: inherit; text-decoration: none; }
.brand-mark { display: grid; width: 40px; height: 40px; place-items: center; border-radius: 11px; background: linear-gradient(145deg, #5babff, #246ac4); box-shadow: 0 8px 30px rgba(49, 135, 238, .24); font-size: 1.2rem; font-weight: 900; }
.brand strong, .brand small { display: block; line-height: 1.05; }
.brand small { margin-top: 4px; color: var(--muted); font-size: .72rem; letter-spacing: .13em; text-transform: uppercase; }
.topbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
+.sidebar-toggle { display: none; flex: none; font-size: 1.05rem; }
+.global-search { width: min(26vw, 360px); }
+.global-search input { width: 100%; margin: 0; padding-block: 9px; background: #091421; }
.server-picker { display: grid; grid-template-columns: auto minmax(190px, 280px); align-items: center; gap: 9px; margin: 0; color: var(--muted); font-size: .78rem; }
.server-picker select { margin: 0; padding: 9px 35px 9px 11px; }
.status { padding: 8px 12px; border: 1px solid var(--border-strong); border-radius: 999px; color: var(--muted); font-size: .78rem; white-space: nowrap; }
.status.online { color: var(--green); border-color: #2b7750; background: var(--green-soft); }
-main { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding: 48px 0 80px; }
+main { width: min(1480px, calc(100% - 40px)); margin: 0 auto; padding: 42px 0 80px; }
.welcome { max-width: 780px; margin: 18px 0 34px; }
.welcome h1 { margin-bottom: 12px; font-size: clamp(2.2rem, 5vw, 4rem); line-height: 1.04; letter-spacing: -.045em; }
.welcome > p:last-child { max-width: 660px; color: var(--muted); font-size: 1.08rem; line-height: 1.7; }
@@ -59,19 +66,53 @@ p { line-height: 1.55; }
.section-title { align-items: flex-start; }
.section-title > div { min-width: 0; }
-.tabs { display: flex; gap: 4px; margin: 0 -20px; padding: 0 20px; overflow-x: auto; border-bottom: 1px solid var(--border); scrollbar-width: thin; }
-.tabs button { flex: 0 0 auto; padding: 14px 15px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--muted); }
-.tabs button:hover:not(:disabled) { background: transparent; color: #fff; transform: none; }
-.tabs button[aria-selected="true"] { border-bottom-color: var(--blue); color: #fff; }
+.app-shell { display: grid; grid-template-columns: 226px minmax(0, 1fr); gap: 34px; align-items: start; }
+.content-shell { min-width: 0; }
+.sidebar { position: sticky; top: 100px; display: flex; max-height: calc(100vh - 120px); flex-direction: column; gap: 2px; padding: 12px; overflow-y: auto; border: 1px solid var(--border); border-radius: 15px; background: rgba(9, 20, 33, .95); box-shadow: 0 20px 60px rgba(0, 0, 0, .18); }
+.sidebar p { margin: 16px 10px 6px; color: #71869f; font-size: .64rem; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; }
+.sidebar p:first-of-type { margin-top: 12px; }
+.sidebar button { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 9px; width: 100%; padding: 10px 11px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-size: .84rem; font-weight: 700; text-align: left; }
+.sidebar button span { color: #7194b9; text-align: center; }
+.sidebar button:hover:not(:disabled) { background: var(--surface-soft); color: #fff; transform: none; }
+.sidebar button[aria-current="page"] { background: linear-gradient(90deg, #163f6d, #112d4c); color: #fff; box-shadow: inset 3px 0 0 var(--blue); }
+.sidebar button[aria-current="page"] span { color: #a9d4ff; }
.tab-panel { animation: enter .16s ease-out; }
@keyframes enter { from { opacity: 0; transform: translateY(3px); } }
-.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; margin-bottom: 18px; }
+.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 13px; margin-bottom: 18px; }
.metrics article { display: flex; min-height: 112px; flex-direction: column; justify-content: space-between; padding: 19px; border: 1px solid var(--border); border-radius: 13px; background: var(--surface); }
.metrics span { color: var(--muted); font-size: .85rem; }
-.metrics strong { font-size: 2rem; }
+.metrics strong { margin: 10px 0 3px; font-size: 1.65rem; line-height: 1.05; }
+.metrics small { color: #71869f; }
+.metrics article.healthy { border-color: #2b7750; background: linear-gradient(145deg, var(--green-soft), var(--surface)); }
+.metrics article.warning { border-color: #785927; background: linear-gradient(145deg, var(--amber-soft), var(--surface)); }
+.metrics article.critical { border-color: #82445a; background: linear-gradient(145deg, #341521, var(--surface)); }
.selected-summary-card { margin-bottom: 18px; background: linear-gradient(135deg, rgba(17, 47, 81, .88), rgba(12, 24, 40, .96)); }
.selected-summary-card > p { color: var(--muted); }
+.overview-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(280px, .75fr); gap: 18px; margin-bottom: 18px; }
+.overview-grid > .card { min-width: 0; }
+.lower-overview-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 18px; }
+.attention-feed, .compact-activity, .activity-bars { display: grid; gap: 10px; margin-top: 18px; }
+.attention-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 11px; align-items: start; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #091421; }
+.attention-indicator { width: 9px; height: 9px; margin-top: 6px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(255, 201, 107, .1); }
+.attention-item.critical .attention-indicator { background: var(--danger); box-shadow: 0 0 0 4px rgba(255, 137, 164, .1); }
+.attention-item.informational .attention-indicator { background: #79b7f2; }
+.attention-item strong, .attention-item small { display: block; }
+.attention-item small { margin-top: 3px; color: var(--muted); line-height: 1.4; }
+.attention-empty { padding: 24px; border: 1px dashed #2b7750; border-radius: 10px; color: var(--green); text-align: center; }
+.quick-action-grid { display: grid; gap: 9px; margin-top: 18px; }
+.quick-action-grid button { display: flex; align-items: center; justify-content: space-between; width: 100%; border-color: var(--border); background: #091421; color: #dceaff; text-align: left; }
+.quick-action-grid button::after { color: #7194b9; content: "→"; }
+.activity-bar-row { display: grid; grid-template-columns: minmax(95px, .8fr) minmax(100px, 1.2fr) auto; gap: 10px; align-items: center; font-size: .82rem; }
+.activity-bar-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.activity-bar-track { width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: 999px; background: #07101d; appearance: none; }
+.activity-bar-track::-webkit-progress-bar { border-radius: inherit; background: #07101d; }
+.activity-bar-track::-webkit-progress-value { border-radius: inherit; background: linear-gradient(90deg, #3187ee, #79e6a3); }
+.activity-bar-track::-moz-progress-bar { border-radius: inherit; background: linear-gradient(90deg, #3187ee, #79e6a3); }
+.activity-item { padding: 11px 0; border-bottom: 1px solid var(--border); }
+.activity-item:last-child { border-bottom: 0; }
+.activity-item strong, .activity-item small { display: block; }
+.activity-item small { margin-top: 4px; color: var(--muted); }
.pills { display: flex; flex-wrap: wrap; gap: 7px; }
.pill { display: inline-flex; align-items: center; width: fit-content; padding: 5px 9px; border: 1px solid #37628d; border-radius: 999px; background: var(--blue-soft); color: #a9d4ff; font-size: .74rem; font-weight: 750; }
.pill.online { border-color: #2b7750; background: var(--green-soft); color: var(--green); }
@@ -211,10 +252,19 @@ li span { color: var(--muted); font-size: .9rem; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 920px) {
- .topbar { position: static; align-items: flex-start; }
+ .topbar { align-items: center; }
+ .sidebar-toggle { display: inline-flex; align-items: center; justify-content: center; }
.topbar-actions { flex-wrap: wrap; }
+ .global-search { order: 4; width: 100%; }
.server-picker { order: 3; width: 100%; grid-template-columns: auto 1fr; }
+ .app-shell { display: block; }
+ body.sidebar-open { overflow: hidden; }
+ body::before { position: fixed; z-index: 25; top: 76px; right: 0; bottom: 0; left: 0; visibility: hidden; background: rgba(2, 8, 15, .62); opacity: 0; content: ""; transition: opacity .18s ease, visibility 0s linear .18s; }
+ body.sidebar-open::before { visibility: visible; opacity: 1; transition-delay: 0s; }
+ .sidebar { position: fixed; z-index: 30; top: 76px; bottom: 0; left: 0; visibility: hidden; width: min(300px, calc(100vw - 42px)); max-height: none; border-radius: 0 15px 15px 0; transform: translateX(-105%); transition: transform .18s ease, visibility 0s linear .18s; }
+ body.sidebar-open .sidebar { visibility: visible; transform: translateX(0); box-shadow: 20px 0 80px rgba(0, 0, 0, .6); transition-delay: 0s; }
.metrics, .settings-grid, .feature-grid { grid-template-columns: repeat(2, 1fr); }
+ .overview-grid { grid-template-columns: 1fr; }
.sync-grid { grid-template-columns: 1fr; }
.roadmap-card { grid-template-columns: 1fr; }
.roadmap-card > div { border-right: 0; border-bottom: 1px solid var(--border); }
@@ -222,20 +272,39 @@ li span { color: var(--muted); font-size: .9rem; }
}
@media (max-width: 640px) {
- .topbar { flex-direction: column; padding: 14px 18px; }
- .topbar-actions { width: 100%; justify-content: flex-start; }
+ .topbar { padding: 12px 14px; }
+ .brand { margin-right: auto; }
+ .brand > span:last-child { display: none; }
+ .brand-mark { width: 36px; height: 36px; }
+ .topbar-actions { display: contents; }
+ .status { display: none; }
+ #header-action { display: none; }
.server-picker { grid-template-columns: 1fr; }
- main { width: min(100% - 28px, 1240px); padding-top: 30px; }
+ .server-picker span { display: none; }
+ .server-picker select { width: min(48vw, 220px); }
+ .global-search { position: absolute; top: 74px; right: 14px; left: 14px; width: auto; }
+ main { width: min(100% - 28px, 1480px); padding-top: 72px; }
.welcome h1 { font-size: 2.35rem; }
.page-heading, .section-title, .auth-row, .editor-footer { align-items: stretch; flex-direction: column; }
.page-heading > button, .auth-row button { width: 100%; }
.metrics, .settings-grid, .feature-grid, .checklist { grid-template-columns: 1fr; }
.metrics article { min-height: 92px; }
- .tabs { margin: 0 -14px; padding: 0 14px; }
.subtabs button { padding-inline: 8px; }
.editor-toolbar { grid-template-columns: 1fr; }
.editor-toolbar > span { justify-self: start; }
.yaml-editor { min-height: 420px; padding: 16px; }
.operation-actions button { width: 100%; }
.result-item { align-items: stretch; flex-direction: column; }
+ .attention-item { grid-template-columns: auto minmax(0, 1fr); }
+ .attention-item button { grid-column: 2; width: 100%; }
+}
+
+@media (max-width: 420px) {
+ .topbar { gap: 8px; }
+ .brand { display: none; }
+ .server-picker select { width: min(50vw, 180px); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; }
}
diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js
index cc3dd03e..aa3ec84b 100644
--- a/src/main/resources/web/app.js
+++ b/src/main/resources/web/app.js
@@ -15,16 +15,34 @@ const logout = document.querySelector('#logout');
const message = document.querySelector('#message');
const welcome = document.querySelector('#welcome');
const appShell = document.querySelector('#app-shell');
+const sidebarToggle = document.querySelector('#sidebar-toggle');
+const primaryNavigation = document.querySelector('#primary-navigation');
+const globalSearch = document.querySelector('#global-search');
+const globalSearchInput = document.querySelector('#global-search-input');
+const globalSearchOptions = document.querySelector('#global-search-options');
+const headerAction = document.querySelector('#header-action');
const serverPickerLabel = document.querySelector('#server-picker-label');
const serverPicker = document.querySelector('#server-picker');
const tabButtons = [...document.querySelectorAll('[data-tab]')];
+const navigationButtons = [...primaryNavigation.querySelectorAll('button')];
const tabPanels = [...document.querySelectorAll('[data-panel]')];
const configViewButtons = [...document.querySelectorAll('[data-config-view]')];
const configViewPanels = [...document.querySelectorAll('[data-config-panel]')];
-const metricNodes = document.querySelector('#metric-nodes');
-const metricOnline = document.querySelector('#metric-online');
-const metricBackends = document.querySelector('#metric-backends');
+const metricHealth = document.querySelector('#metric-health');
+const metricHealthDetail = document.querySelector('#metric-health-detail');
+const metricVotesToday = document.querySelector('#metric-votes-today');
+const metricVoteSites = document.querySelector('#metric-vote-sites');
+const metricVoteSitesDetail = document.querySelector('#metric-vote-sites-detail');
+const metricProxy = document.querySelector('#metric-proxy');
+const metricProxyDetail = document.querySelector('#metric-proxy-detail');
+const metricLoggedEvents = document.querySelector('#metric-logged-events');
const metricIssues = document.querySelector('#metric-issues');
+const metricIssuesDetail = document.querySelector('#metric-issues-detail');
+const refreshDashboardButton = document.querySelector('#refresh-dashboard');
+const attentionFeed = document.querySelector('#attention-feed');
+const overviewQuickActions = document.querySelector('#overview-quick-actions');
+const voteActivity = document.querySelector('#vote-activity');
+const overviewActivity = document.querySelector('#overview-activity');
const selectedServerName = document.querySelector('#selected-server-name');
const selectedServerState = document.querySelector('#selected-server-state');
const selectedServerSummary = document.querySelector('#selected-server-summary');
@@ -259,6 +277,13 @@ let configurationFileSelection = configurationFile.value;
let inspectionInFlight = false;
let lastDiagnostics = null;
let lastOverview = null;
+let dashboardOverview = null;
+let dashboardVoteSiteHealth = null;
+let dashboardVoteSummary24h = null;
+let dashboardVoteSummary30d = null;
+let dashboardLoadedContext = '';
+let dashboardTopologySignature = '';
+let dashboardLoading = false;
let operationHistoryItems = [];
let dedicatedSetupApprovals = new Map();
let pendingDetectedVoteSite = null;
@@ -803,6 +828,7 @@ function renderOperationHistory() {
operationHistory.replaceChildren();
if (operationHistoryItems.length === 0) {
text(operationHistory, 'No retained configuration operations.');
+ renderMetrics();
return;
}
operationHistoryItems.forEach(operation => {
@@ -876,6 +902,7 @@ function renderOperationHistory() {
item.append(heading, detail);
operationHistory.append(item);
});
+ renderMetrics();
}
async function loadOperationHistory() {
@@ -1035,6 +1062,12 @@ function applyAuthenticatedSession(body) {
lastFileReadOperation = null;
lastDiagnostics = null;
lastOverview = null;
+ dashboardOverview = null;
+ dashboardVoteSiteHealth = null;
+ dashboardVoteSummary24h = null;
+ dashboardVoteSummary30d = null;
+ dashboardLoadedContext = '';
+ dashboardTopologySignature = '';
operationHistoryItems = [];
dedicatedSetupApprovals.clear();
voteLoggingRestartPending.clear();
@@ -1051,6 +1084,9 @@ function applyAuthenticatedSession(body) {
autoLoadInFlight.clear();
inputGeneration++;
logout.hidden = false;
+ sidebarToggle.hidden = false;
+ globalSearch.hidden = false;
+ headerAction.hidden = false;
authCard.hidden = true;
welcome.hidden = true;
appShell.hidden = false;
@@ -1216,14 +1252,68 @@ function tabFromHash() {
return tabPanels.some(panel => panel.dataset.panel === requested) ? requested : 'overview';
}
+function closeSidebar() {
+ const restoreFocus = window.matchMedia('(max-width: 920px)').matches
+ && primaryNavigation.contains(document.activeElement);
+ document.body.classList.remove('sidebar-open');
+ sidebarToggle.setAttribute('aria-expanded', 'false');
+ sidebarToggle.setAttribute('aria-label', 'Open navigation');
+ if (restoreFocus) sidebarToggle.focus();
+}
+
+function updateHeaderAction(tab) {
+ const actions = {
+ overview: ['Refresh dashboard', () => refreshDashboard()],
+ servers: ['Refresh servers', () => loadNodes()],
+ network: ['Run Network Doctor', () => runNetworkDoctor.click()],
+ configurations: ['Compare configuration', () => runDriftCheck.click()],
+ 'quick-setup': ['Add Vote Site', () => openWorkspace('quick-setup', 'quick-setup-card', 'vote-site')],
+ data: ['Refresh server overview', () => refreshDataOverview.click()],
+ activity: ['Refresh activity', () => loadOperationHistory()],
+ access: ['Refresh access', () => loadEnrollments()]
+ };
+ const [label, action] = actions[tab] || actions.overview;
+ text(headerAction, label);
+ headerAction.onclick = action;
+ const unavailable = tab === 'overview' ? !inspectionCapableNode() || dashboardLoading
+ : tab === 'network' ? runNetworkDoctor.disabled
+ : tab === 'configurations' ? runDriftCheck.disabled
+ : tab === 'data' ? refreshDataOverview.disabled
+ : tab === 'quick-setup' ? !nodeCapabilities.get(selectedServerId)?.includes('config.quick-setup.v1')
+ : false;
+ headerAction.disabled = !authenticated || unavailable;
+}
+
function setActiveTab(tab, updateHash = false) {
if (!tabPanels.some(panel => panel.dataset.panel === tab)) tab = 'overview';
- tabButtons.forEach(button => button.setAttribute('aria-selected', String(button.dataset.tab === tab)));
+ navigationButtons.forEach(button => button.removeAttribute('aria-current'));
+ tabButtons.forEach(button => {
+ if (button.dataset.tab === tab) button.setAttribute('aria-current', 'page');
+ });
tabPanels.forEach(panel => { panel.hidden = panel.dataset.panel !== tab; });
if (updateHash && window.location.hash !== `#${tab}`) window.history.replaceState(null, '', `#${tab}`);
+ closeSidebar();
+ updateHeaderAction(tab);
void autoLoadTab(tab);
}
+function openWorkspace(tab, scrollTarget = '', preset = '', navigationButton = null) {
+ setActiveTab(tab, true);
+ if (navigationButton && primaryNavigation.contains(navigationButton)) {
+ navigationButtons.forEach(button => button.removeAttribute('aria-current'));
+ navigationButton.setAttribute('aria-current', 'page');
+ }
+ if (preset) {
+ quickPreset.value = preset;
+ loadedQuickSetup = null;
+ updateQuickFields();
+ clearApprovals();
+ }
+ if (scrollTarget) window.requestAnimationFrame(() => {
+ document.getElementById(scrollTarget)?.scrollIntoView({behavior: 'smooth', block: 'start'});
+ });
+}
+
function setConfigView(view) {
if (!configViewPanels.some(panel => panel.dataset.configPanel === view)) view = 'easy';
configViewButtons.forEach(button => button.classList.toggle('active', button.dataset.configView === view));
@@ -1286,6 +1376,12 @@ function syncFileSelection() {
async function autoLoadTab(tab) {
if (!authenticated || autoLoadInFlight.has(tab)) return;
+ if (tab === 'overview' && inspectionCapableNode() && dashboardLoadedContext !== dashboardContext()
+ && !dashboardLoading && !inspectionInFlight) {
+ autoLoadInFlight.add(tab);
+ try { await refreshDashboard(); } finally { autoLoadInFlight.delete(tab); }
+ return;
+ }
if (tab === 'configurations') {
const yamlVisible = configViewPanels.some(panel => panel.dataset.configPanel === 'yaml' && !panel.hidden);
if (!yamlVisible || configurationDirty || configurationContentPresent || !fileTargetsForSelection().length) return;
@@ -1339,6 +1435,7 @@ function renderServerPicker() {
}
}
serverPicker.value = selectedServerId;
+ populateGlobalSearch();
}
function renderSelectedServer() {
@@ -1436,23 +1533,251 @@ function renderTopology() {
}
}
+function dashboardContext() {
+ const node = nodeIndex.get(selectedServerId);
+ return node ? `${node.nodeId}|${node.sessionId}|${node.online}|${node.acceptedCapabilities.includes('data.inspect.v1')}|${dashboardTopologySignature}` : '';
+}
+
+function topologySignature(items) {
+ return items.filter(isProxy).map(proxy => `${proxy.nodeId}|${proxy.sessionId}|${proxy.online}|${proxy.snapshotSequence}`).join(';');
+}
+
+function issue(severity, title, detail, action, tab, scrollTarget = '', preset = '') {
+ return {severity, title, detail, action, tab, scrollTarget, preset};
+}
+
+function dashboardIssues() {
+ const issues = [];
+ const selected = nodeIndex.get(selectedServerId);
+ if (!selected) {
+ issues.push(issue('informational', 'Choose a VotingPlugin server',
+ 'Server-specific health and administration actions need an explicit target.', 'Choose server', 'servers'));
+ } else if (!selected.online) {
+ issues.push(issue('critical', `${selected.displayName} is disconnected from Control`,
+ 'Minecraft presence is separate; reconnect the Control node before attempting changes.', 'View server', 'servers'));
+ } else if (dashboardLoadedContext !== dashboardContext()) {
+ issues.push(issue('informational', 'Detailed health has not been loaded',
+ 'Refresh the dashboard to inspect Vote Sites, VoteLog, configuration, and runtime state.', 'Refresh dashboard', 'overview'));
+ }
+ if (backendTopologyTruncated) {
+ issues.push(issue('informational', 'Topology view is incomplete',
+ 'Control bounded the backend summaries, so omitted relationships are not classified as offline.', 'View servers', 'servers'));
+ }
+ const disconnectedNodes = allNodeItems.filter(node => !node.online && node.nodeId !== selectedServerId);
+ disconnectedNodes.slice(0, 10).forEach(node => {
+ issues.push(issue('warning', `${node.displayName} is disconnected from Control`,
+ `The registered ${roleLabel(node).toLowerCase()} is not currently connected. Minecraft availability is separate.`,
+ 'View server', 'servers'));
+ });
+ if (disconnectedNodes.length > 10) {
+ issues.push(issue('informational', `${disconnectedNodes.length - 10} additional nodes are disconnected`,
+ 'Open Servers to inspect the complete registered-node state.', 'View servers', 'servers'));
+ }
+ allNodeItems.filter(node => isProxy(node) && node.online).forEach(proxy => {
+ (Array.isArray(proxy.backends) ? proxy.backends : []).slice(0, 100).forEach(backend => {
+ const registered = nodeIndex.get(backend.backendId);
+ if (!registered) issues.push(issue('warning', `${backend.displayName} is not registered with Control`,
+ `${proxy.displayName} reports this backend, but Control has no current node record.`, 'View servers', 'servers'));
+ if (enrollmentsLoaded && !enrollmentIds.has(backend.backendId)) {
+ issues.push(issue('warning', `${backend.displayName} is not enrolled`,
+ 'Enroll the node before expecting authenticated Control connectivity.', 'Open access', 'access'));
+ }
+ if (backend.presenceKnown && !backend.available) issues.push(issue('warning', `${backend.displayName} is unavailable to the proxy`,
+ `${proxy.displayName} reported a known Minecraft availability failure.`, 'Test communication', 'network', 'transport-test-card'));
+ });
+ });
+ if (dashboardOverview && dashboardLoadedContext === dashboardContext()) {
+ if (dashboardOverview.configurationHealthy === false) issues.push(issue('warning', 'VotingPlugin configuration needs attention',
+ 'The selected backend reported an unhealthy configuration state.', 'Open configuration', 'configurations'));
+ if (dashboardOverview.votifierDetected === false) issues.push(issue('warning', 'Votifier was not detected',
+ 'The selected backend cannot confirm the vote-listener prerequisite.', 'Diagnose', 'network', 'network-doctor-card'));
+ if (Number(dashboardOverview.configuredVoteSites) === 0) issues.push(issue('warning', 'No Vote Sites are configured',
+ 'Add a reviewed VoteSites entry before expecting service matches.', 'Add Vote Site', 'quick-setup', 'quick-setup-card', 'vote-site'));
+ if (dashboardOverview.processRewards === false) issues.push(issue('warning', 'Vote rewards are disabled on this backend',
+ 'ProcessRewards is off. Confirm that this is intentional for the selected topology.', 'Open setting', 'quick-setup', 'settings-catalog-card'));
+ if (dashboardOverview.voteLoggingEnabled === true && dashboardOverview.voteLogReadable !== true) issues.push(issue('warning', 'VoteLog is unavailable',
+ 'Logging is enabled, but retained logged-event history is not readable.', 'Open Vote Logs', 'data', 'vote-log-summary-result'));
+ if (voteLoggingRestartRequired()) issues.push(issue('warning', `Restart required on ${selected?.displayName || selectedServerId}`,
+ 'Vote logging configuration was saved but is not live until the backend restarts and reconnects.', 'Open setup', 'quick-setup'));
+ if (dashboardOverview.proxyMode === true && proxyReportsFor(selectedServerId).length === 0) issues.push(issue('warning', 'No proxy reports this backend',
+ 'Proxy mode is enabled, but no connected proxy currently reports the selected backend ID.', 'Open routing', 'network'));
+ }
+ if (dashboardVoteSiteHealth && dashboardLoadedContext === dashboardContext()) {
+ const sites = Array.isArray(dashboardVoteSiteHealth.sites) ? dashboardVoteSiteHealth.sites : [];
+ sites.filter(site => site.status === 'SERVICE_SITE_MISSING').slice(0, 10).forEach(site => {
+ issues.push(issue('warning', `${site.displayName || site.key} has no ServiceSite`,
+ 'Votes cannot be matched reliably until the service identifier is configured.', 'Open Vote Sites', 'data', 'site-health-card'));
+ });
+ (dashboardVoteSiteHealth.detectedUnconfiguredServices || []).slice(0, 10).forEach(service => {
+ issues.push(issue('warning', `Detected service is not configured: ${service}`,
+ dashboardVoteSiteHealth.autoCreateVoteSites === false
+ ? 'AutoCreateVoteSites is disabled; review and create this site explicitly.'
+ : 'VotingPlugin observed this service but no configured ServiceSite matches it.',
+ 'Configure', 'quick-setup', 'quick-setup-card', 'vote-site'));
+ });
+ (dashboardVoteSiteHealth.unmatchedLoggedServices || []).slice(0, 10).forEach(service => {
+ issues.push(issue('warning', `Logged service does not match a Vote Site: ${service.serviceSite || 'Unknown'}`,
+ 'A retained vote event used a service identifier with no configured match.', 'Open Vote Sites', 'data', 'site-health-card'));
+ });
+ }
+ operationHistoryItems.filter(operation => ['FAILED', 'COMPLETED_WITH_ERRORS'].includes(operation.state)).slice(0, 5)
+ .forEach(operation => issues.push(issue('warning', `${operationLabel(operation)} needs review`,
+ operationPhase(operation), 'View operation', 'activity')));
+ return issues.slice(0, 30);
+}
+
+function renderAttention(issues) {
+ attentionFeed.replaceChildren();
+ if (issues.length === 0) {
+ const empty = text(document.createElement('p'), 'No actionable problems were found in the currently loaded state.');
+ empty.className = 'attention-empty';
+ attentionFeed.append(empty);
+ return;
+ }
+ issues.slice(0, 8).forEach(item => {
+ const row = document.createElement('article');
+ row.className = `attention-item ${item.severity}`;
+ const indicator = document.createElement('span');
+ indicator.className = 'attention-indicator';
+ indicator.setAttribute('aria-label', item.severity);
+ const copy = document.createElement('div');
+ copy.append(text(document.createElement('strong'), item.title), text(document.createElement('small'), item.detail));
+ const action = text(document.createElement('button'), item.action);
+ action.type = 'button';
+ action.className = 'secondary compact';
+ action.addEventListener('click', () => openWorkspace(item.tab, item.scrollTarget, item.preset));
+ row.append(indicator, copy, action);
+ attentionFeed.append(row);
+ });
+ if (issues.length > 8) attentionFeed.append(text(document.createElement('small'), `${issues.length - 8} additional issues are available in their related pages.`));
+}
+
+function addQuickAction(label, tab, scrollTarget = '', preset = '') {
+ const button = text(document.createElement('button'), label);
+ button.type = 'button';
+ button.className = 'secondary';
+ button.addEventListener('click', () => openWorkspace(tab, scrollTarget, preset));
+ overviewQuickActions.append(button);
+}
+
+function renderQuickActions() {
+ overviewQuickActions.replaceChildren();
+ const selected = nodeIndex.get(selectedServerId);
+ if (selected?.online && isBackend(selected) && selected.acceptedCapabilities.includes('config.quick-setup.v1')) {
+ addQuickAction('Add Vote Site', 'quick-setup', 'quick-setup-card', 'vote-site');
+ addQuickAction('Build Reward', 'quick-setup', 'reward-builder-card');
+ }
+ const syncSources = syncSourceCandidates();
+ const syncTargets = syncTargetCandidates();
+ if (syncSources.some(source => syncTargets.some(target => target.nodeId !== source.nodeId))) {
+ addQuickAction('Sync Vote Sites', 'quick-setup', 'quick-setup-card', 'sync-vote-sites');
+ }
+ if (inspectionCapableNode()) addQuickAction('Run Network Doctor', 'network', 'network-doctor-card');
+ if (transportTestProxies().length) addQuickAction('Test Communication', 'network', 'transport-test-card');
+ if (inspectionCapableNode()) addQuickAction('View Logged Events', 'data', 'vote-log-summary-result');
+ if (overviewQuickActions.childElementCount === 0) {
+ addQuickAction(allNodeItems.length ? 'View Servers' : 'Refresh Servers', 'servers');
+ }
+}
+
+function renderVoteActivity() {
+ voteActivity.replaceChildren();
+ if (!authenticated || !inspectionCapableNode() || dashboardLoadedContext !== dashboardContext()) {
+ text(voteActivity, 'No current retained VoteLog service summary is loaded.');
+ return;
+ }
+ const services = Array.isArray(dashboardVoteSummary30d?.topServices)
+ ? dashboardVoteSummary30d.topServices.slice(0, 6) : [];
+ if (services.length === 0) {
+ text(voteActivity, dashboardOverview?.voteLoggingEnabled === false
+ ? 'Vote logging is disabled; no retained event chart is expected.' : 'No readable retained VoteLog service summary is loaded.');
+ return;
+ }
+ const maximum = Math.max(...services.map(service => Number(service.votes) || 0), 1);
+ services.forEach(service => {
+ const row = document.createElement('div');
+ row.className = 'activity-bar-row';
+ row.append(text(document.createElement('strong'), service.service || 'Unknown service'));
+ const track = document.createElement('progress');
+ track.className = 'activity-bar-track';
+ track.max = maximum;
+ track.value = Number(service.votes) || 0;
+ track.setAttribute('aria-label', `${service.service || 'Unknown service'} logged votes`);
+ row.append(track, text(document.createElement('span'), Number(service.votes) || 0));
+ voteActivity.append(row);
+ });
+}
+
+function operationLabel(operation) {
+ const configuration = operation.configuration || {};
+ if (configuration.domain === 'quick-setup') {
+ return ({'vote-site': 'Vote Site change', 'sync-vote-sites': 'Vote Site synchronization',
+ 'reward-builder': 'Reward update', 'proxy-method': 'Proxy method change',
+ 'vote-logging': 'Vote logging change'}[configuration.preset] || 'Guided setup change');
+ }
+ if (configuration.domain === 'file') return `${configuration.fileName || 'Configuration'} change`;
+ if (configuration.domain === 'proxy-routing') return 'Proxy routing change';
+ return 'Configuration operation';
+}
+
+function renderOverviewActivity() {
+ overviewActivity.replaceChildren();
+ if (operationHistoryItems.length === 0) {
+ text(overviewActivity, 'No retained configuration operations.');
+ return;
+ }
+ operationHistoryItems.slice(0, 5).forEach(operation => {
+ const item = document.createElement('article');
+ item.className = 'activity-item';
+ const results = Object.values(operation.results || {});
+ const successful = results.filter(result => result?.success).length;
+ item.append(text(document.createElement('strong'), operationLabel(operation)));
+ const when = operation.createdAt ? new Date(operation.createdAt).toLocaleString() : 'Time unavailable';
+ item.append(text(document.createElement('small'), `${operationPhase(operation)} · ${successful}/${results.length} targets successful · ${when}`));
+ overviewActivity.append(item);
+ });
+}
+
function renderMetrics() {
- const backendIds = new Set(allNodeItems.filter(isProxy).flatMap(node =>
- (Array.isArray(node.backends) ? node.backends : []).map(backend => backend.backendId)));
- const issueIds = new Set(allNodeItems.filter(node => !node.online).map(node => node.nodeId));
- backendIds.forEach(backendId => { if (!nodeIndex.has(backendId)) issueIds.add(backendId); });
- if (enrollmentsLoaded) {
- backendIds.forEach(backendId => { if (!enrollmentIds.has(backendId)) issueIds.add(backendId); });
- }
- allNodeItems.filter(node => isProxy(node) && node.online).forEach(proxy =>
- (Array.isArray(proxy.backends) ? proxy.backends : []).forEach(backend => {
- if (backend.presenceKnown && !backend.available) issueIds.add(backend.backendId);
- }));
- text(metricNodes, allNodeItems.length);
- text(metricOnline, allNodeItems.filter(node => node.online).length);
- text(metricBackends, backendTopologyTruncated ? `${backendIds.size}+` : backendIds.size);
- const issueCount = issueIds.size;
- text(metricIssues, backendTopologyTruncated ? `${issueCount}+` : issueCount);
+ const issues = dashboardIssues();
+ const selected = nodeIndex.get(selectedServerId);
+ const current = dashboardLoadedContext === dashboardContext();
+ const hasCritical = issues.some(item => item.severity === 'critical');
+ const hasWarning = issues.some(item => item.severity === 'warning');
+ const state = !selected ? 'Unavailable' : !selected.online || hasCritical ? 'Critical'
+ : !current || hasWarning ? 'Warning' : 'Healthy';
+ text(metricHealth, state);
+ text(metricHealthDetail, !selected ? 'Choose a server' : !selected.online ? 'Control disconnected'
+ : !current ? 'Inspection not loaded' : hasCritical ? 'Immediate attention required'
+ : hasWarning ? 'Review observed warnings' : 'No observed problems');
+ metricHealth.closest('article').className = selected ? state.toLowerCase() : '';
+ const votes24h = current ? finiteCount(dashboardVoteSummary24h?.total) : null;
+ text(metricVotesToday, votes24h == null ? '—' : votes24h);
+ const configured = current ? finiteCount(dashboardOverview?.configuredVoteSites) : null;
+ const enabled = current ? finiteCount(dashboardOverview?.enabledVoteSites) : null;
+ const siteWarnings = current && Array.isArray(dashboardVoteSiteHealth?.sites)
+ ? dashboardVoteSiteHealth.sites.filter(site => site.status === 'SERVICE_SITE_MISSING').length
+ + (dashboardVoteSiteHealth.detectedUnconfiguredServices?.length || 0) : null;
+ text(metricVoteSites, configured == null ? '—' : `${enabled}/${configured}`);
+ text(metricVoteSitesDetail, configured == null ? 'Not inspected'
+ : `${enabled} enabled${siteWarnings == null ? '' : ` · ${siteWarnings} need attention`}`);
+ text(metricProxy, !current ? '—' : dashboardOverview?.proxyMode === false ? 'Standalone' : dashboardOverview?.proxyMethod || 'Unknown');
+ text(metricProxyDetail, !current ? 'Method unavailable' : dashboardOverview?.proxyMode === false
+ ? 'Proxy mode disabled' : proxyReportsFor(selectedServerId).length ? 'Proxy relationship reported' : 'No reporting proxy');
+ const logged30d = current ? finiteCount(dashboardVoteSummary30d?.total) : null;
+ text(metricLoggedEvents, logged30d == null ? '—' : logged30d);
+ const actionable = issues.filter(item => item.severity !== 'informational').length;
+ text(metricIssues, actionable >= 30 ? `${actionable}+` : actionable);
+ text(metricIssuesDetail, actionable ? `${actionable} actionable` : 'No observed problems');
+ renderAttention(issues);
+ renderQuickActions();
+ renderVoteActivity();
+ renderOverviewActivity();
+}
+
+function finiteCount(value) {
+ const count = Number(value);
+ return Number.isFinite(count) && count >= 0 ? count : null;
}
function syncSourceCandidates() {
@@ -1698,6 +2023,7 @@ function updateExtendedButtons() {
&& configurationOperationsInFlight === 0;
const fileTargets = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId)));
const driftReady = authenticated && fileTargets.length >= 2 && configurationOperationsInFlight === 0;
+ refreshDashboardButton.disabled = !inspectionReady || dashboardLoading;
runNetworkDoctor.disabled = !inspectionReady;
downloadNetworkDiagnostics.disabled = !lastDiagnostics;
refreshSetupChecklist.disabled = !inspectionReady;
@@ -1731,6 +2057,7 @@ function updateExtendedButtons() {
rewardSimulationCapability.className = `pill ${rewardReady ? 'online' : 'neutral'}`;
text(driftCapability, driftReady ? `${fileTargets.length} selected nodes ready` : 'Select at least two readable nodes');
driftCapability.className = `pill ${driftReady ? 'online' : 'neutral'}`;
+ updateHeaderAction(tabFromHash());
updateSetupChecklist();
}
@@ -1792,6 +2119,12 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) {
lastFileReadOperation = null;
lastDiagnostics = null;
lastOverview = null;
+ dashboardOverview = null;
+ dashboardVoteSiteHealth = null;
+ dashboardVoteSummary24h = null;
+ dashboardVoteSummary30d = null;
+ dashboardLoadedContext = '';
+ dashboardTopologySignature = '';
downloadNetworkDiagnostics.disabled = true;
voteSitesSourceId = '';
voteSitesTargetIds.clear();
@@ -1822,6 +2155,7 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) {
quickPreset.value = preset;
updateQuickFields();
text(quickOperationStatus, reason);
+ renderMetrics();
}
function selectPrimaryServer(nodeId) {
@@ -1837,6 +2171,7 @@ function selectPrimaryServer(nodeId) {
resetServerContextValues('Server changed. Load current values before continuing.');
updatePluginSuggestions();
renderNodeViews();
+ void autoLoadTab(tabFromHash());
}
function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 || proxyMethodWorkflowInFlight) {
@@ -2004,6 +2339,10 @@ function discardAuthenticationState(reason) {
loadedQuickSetup = null;
inputGeneration++;
logout.hidden = true;
+ sidebarToggle.hidden = true;
+ globalSearch.hidden = true;
+ headerAction.hidden = true;
+ closeSidebar();
appShell.hidden = true;
serverPickerLabel.hidden = true;
welcome.hidden = false;
@@ -2026,6 +2365,12 @@ function discardAuthenticationState(reason) {
lastFileReadOperation = null;
lastDiagnostics = null;
lastOverview = null;
+ dashboardOverview = null;
+ dashboardVoteSiteHealth = null;
+ dashboardVoteSummary24h = null;
+ dashboardVoteSummary30d = null;
+ dashboardLoadedContext = '';
+ dashboardTopologySignature = '';
operationHistoryItems = [];
dedicatedSetupApprovals.clear();
voteLoggingRestartPending.clear();
@@ -2191,6 +2536,11 @@ async function waitForOperation(operation, statusElement = operationStatus, cont
lastFileReadOperation = null;
lastOverview = null;
lastDiagnostics = null;
+ dashboardOverview = null;
+ dashboardVoteSiteHealth = null;
+ dashboardVoteSummary24h = null;
+ dashboardVoteSummary30d = null;
+ dashboardLoadedContext = '';
updateExtendedButtons();
}
return operation;
@@ -2329,6 +2679,7 @@ async function loadNodes() {
const previousNodeIndex = nodeIndex;
visibleNodeItems = registry.items.slice(pageOffset, pageOffset + PAGE_SIZE);
allNodeItems = registry.items;
+ dashboardTopologySignature = topologySignature(registry.items);
backendTopologyTruncated = registry.truncated;
backendTopologyTruncatedNodeIds = registry.truncatedNodeIds;
nodeIndex = new Map(registry.items.map(node => [node.nodeId, node]));
@@ -2355,6 +2706,11 @@ async function loadNodes() {
dedicatedSetupApprovals.clear();
lastOverview = null;
lastDiagnostics = null;
+ dashboardOverview = null;
+ dashboardVoteSiteHealth = null;
+ dashboardVoteSummary24h = null;
+ dashboardVoteSummary30d = null;
+ dashboardLoadedContext = '';
inputGeneration++;
text(operationStatus, routingDraftStatus('A selected node changed capabilities during refresh. Preview again before apply.'));
}
@@ -3188,6 +3544,60 @@ async function refreshOverview(target = dataOverview) {
} catch (error) { text(target, error.message); }
}
+async function refreshDashboard() {
+ if (!inspectionCapableNode() || dashboardLoading) {
+ renderMetrics();
+ return;
+ }
+ const requestedContext = dashboardContext();
+ dashboardLoading = true;
+ dashboardLoadedContext = '';
+ dashboardOverview = null;
+ dashboardVoteSiteHealth = null;
+ dashboardVoteSummary24h = null;
+ dashboardVoteSummary30d = null;
+ refreshDashboardButton.disabled = true;
+ text(attentionFeed, 'Inspecting the selected VotingPlugin server…');
+ try {
+ const overview = await runInspection('overview');
+ dashboardOverview = overview.result;
+ lastOverview = dashboardOverview;
+ updateSetupChecklist(lastOverview);
+ try {
+ dashboardVoteSiteHealth = (await runInspection('vote-site-health', {days: '30'})).result;
+ } catch (error) {
+ if (requestedContext !== dashboardContext()) throw error;
+ dashboardVoteSiteHealth = null;
+ }
+ if (dashboardOverview.voteLogReadable === true) {
+ try {
+ dashboardVoteSummary24h = (await runInspection('vote-log-summary', {days: '1'})).result;
+ } catch (error) {
+ if (requestedContext !== dashboardContext()) throw error;
+ dashboardVoteSummary24h = null;
+ }
+ try {
+ dashboardVoteSummary30d = (await runInspection('vote-log-summary', {days: '30'})).result;
+ } catch (error) {
+ if (requestedContext !== dashboardContext()) throw error;
+ dashboardVoteSummary30d = null;
+ }
+ }
+ if (requestedContext === dashboardContext()) dashboardLoadedContext = requestedContext;
+ } catch (error) {
+ if (requestedContext === dashboardContext()) {
+ text(attentionFeed, error.message || 'Dashboard inspection failed.');
+ }
+ } finally {
+ dashboardLoading = false;
+ renderMetrics();
+ updateExtendedButtons();
+ if (requestedContext !== dashboardContext() && tabFromHash() === 'overview') {
+ window.setTimeout(() => void autoLoadTab('overview'), 0);
+ }
+ }
+}
+
refreshSetupChecklist.addEventListener('click', async () => {
try {
const envelope = await runInspection('diagnostics', {}, setupChecklistStatus);
@@ -3383,13 +3793,22 @@ playerLookupForm.addEventListener('submit', async event => {
});
loadSiteHealth.addEventListener('click', async () => {
- try { renderSiteHealthResult((await runInspection('vote-site-health', {days: '30'}, siteHealthResult)).result); }
+ try {
+ const result = (await runInspection('vote-site-health', {days: '30'}, siteHealthResult)).result;
+ dashboardVoteSiteHealth = result;
+ renderSiteHealthResult(result);
+ renderMetrics();
+ }
catch (error) { text(siteHealthResult, error.message); }
});
loadVoteLogSummary.addEventListener('click', async () => {
- try { renderJsonResult(voteLogSummaryResult,
- (await runInspection('vote-log-summary', {days: '30'}, voteLogSummaryResult)).result); }
+ try {
+ const result = (await runInspection('vote-log-summary', {days: '30'}, voteLogSummaryResult)).result;
+ dashboardVoteSummary30d = result;
+ renderJsonResult(voteLogSummaryResult, result);
+ renderMetrics();
+ }
catch (error) { text(voteLogSummaryResult, error.message); }
});
@@ -3622,7 +4041,7 @@ serverPicker.addEventListener('change', () => selectPrimaryServer(serverPicker.v
tabButtons.forEach(button => button.addEventListener('click', () => setActiveTab(button.dataset.tab, true)));
configViewButtons.forEach(button => button.addEventListener('click', () => setConfigView(button.dataset.configView)));
document.querySelectorAll('[data-open-tab]').forEach(button => button.addEventListener('click', () => {
- setActiveTab(button.dataset.openTab, true);
+ openWorkspace(button.dataset.openTab, button.dataset.scrollTarget, button.dataset.quickPreset, button);
}));
document.querySelectorAll('[data-open-config-view]').forEach(button => button.addEventListener('click', () => {
setActiveTab('configurations', true);
@@ -3633,12 +4052,98 @@ document.querySelectorAll('[data-open-config-view]').forEach(button => button.ad
}
}));
window.addEventListener('hashchange', () => setActiveTab(tabFromHash()));
+sidebarToggle.addEventListener('click', () => {
+ const open = document.body.classList.toggle('sidebar-open');
+ sidebarToggle.setAttribute('aria-expanded', String(open));
+ sidebarToggle.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation');
+ if (open) primaryNavigation.querySelector('button')?.focus();
+});
+document.addEventListener('keydown', event => {
+ if (event.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
+ closeSidebar();
+ sidebarToggle.focus();
+ return;
+ }
+ if (event.key === 'Tab' && document.body.classList.contains('sidebar-open')
+ && window.matchMedia('(max-width: 920px)').matches) {
+ const focusable = navigationButtons.filter(button => !button.disabled);
+ const first = focusable[0];
+ const last = focusable.at(-1);
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+ }
+});
+document.addEventListener('click', event => {
+ if (!document.body.classList.contains('sidebar-open') || primaryNavigation.contains(event.target)
+ || sidebarToggle.contains(event.target)) return;
+ closeSidebar();
+});
+
+function populateGlobalSearch() {
+ const values = ['Add Vote Site', 'Vote Sites', 'Rewards', 'Setup', 'Settings', 'Vote logging',
+ 'Servers', 'Proxy & Routing', 'Sync Vote Sites', 'Votes & Data', 'Activity', 'Network Doctor',
+ 'Configuration Compare', 'Access', ...SETTINGS_SCHEMA.map(setting => setting.key),
+ ...allNodeItems.flatMap(node => [node.displayName, node.nodeId])];
+ const unique = [...new Set(values.filter(Boolean))].slice(0, 300);
+ globalSearchOptions.replaceChildren(...unique.map(value => {
+ const option = document.createElement('option');
+ option.value = value;
+ return option;
+ }));
+}
+
+globalSearch.addEventListener('submit', event => {
+ event.preventDefault();
+ const query = globalSearchInput.value.trim();
+ const normalized = query.toLowerCase();
+ if (!normalized) return;
+ const node = allNodeItems.find(item => item.nodeId.toLowerCase() === normalized
+ || item.displayName.toLowerCase() === normalized)
+ || allNodeItems.find(item => item.nodeId.toLowerCase().includes(normalized)
+ || item.displayName.toLowerCase().includes(normalized));
+ if (node) {
+ selectPrimaryServer(node.nodeId);
+ openWorkspace('servers');
+ globalSearchInput.value = '';
+ return;
+ }
+ const setting = SETTINGS_SCHEMA.find(item => Object.values(item).join(' ').toLowerCase().includes(normalized));
+ if (normalized === 'add vote site') openWorkspace('quick-setup', 'quick-setup-card', 'vote-site');
+ else if (normalized === 'settings') openWorkspace('quick-setup', 'settings-catalog-card');
+ else if (setting) {
+ settingsFilter.value = query;
+ renderSettingsCatalog();
+ openWorkspace('quick-setup', 'settings-catalog-card');
+ } else if (normalized.includes('sync')) openWorkspace('quick-setup', 'quick-setup-card', 'sync-vote-sites');
+ else if (normalized.includes('reward')) openWorkspace('quick-setup', 'reward-builder-card');
+ else if (normalized.includes('vote site') || normalized.includes('service') || normalized.includes('planet')) openWorkspace('data', 'site-health-card');
+ else if (normalized.includes('vote logging')) openWorkspace('quick-setup', 'quick-setup-card', 'vote-logging');
+ else if (normalized.includes('doctor') || normalized.includes('diagnostic')) openWorkspace('network', 'network-doctor-card');
+ else if (['proxy', 'routing', 'redis', 'mqtt', 'mysql', 'sockets', 'transport'].some(value => normalized.includes(value))) openWorkspace('network');
+ else if (normalized.includes('compare') || normalized.includes('drift') || normalized.includes('configuration')) openWorkspace('configurations');
+ else if (normalized.includes('vote') || normalized.includes('data') || normalized.includes('log')) openWorkspace('data');
+ else if (normalized.includes('activity') || normalized.includes('operation') || normalized.includes('history')) openWorkspace('activity');
+ else if (normalized.includes('server') || normalized.includes('topology')) openWorkspace('servers');
+ else if (normalized.includes('access') || normalized.includes('enroll')) openWorkspace('access');
+ else if (normalized.includes('setup')) openWorkspace('quick-setup');
+ else {
+ text(message, `No Control page or setting matched “${query}”.`);
+ return;
+ }
+ globalSearchInput.value = '';
+});
window.addEventListener('beforeunload', event => {
if (!configurationDirty && !routingDirty) return;
event.preventDefault();
event.returnValue = '';
});
refresh.addEventListener('click', loadNodes);
+refreshDashboardButton.addEventListener('click', () => refreshDashboard());
previousPage.addEventListener('click', () => {
pageOffset = Math.max(0, pageOffset - PAGE_SIZE);
loadNodes();
@@ -3654,6 +4159,7 @@ async function initialize() {
updateQuickFields();
updatePluginSuggestions();
renderSettingsCatalog();
+ populateGlobalSearch();
populateProfilePicker();
rewardSiteLabel.hidden = rewardScope.value !== 'site';
updateExtendedButtons();
diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html
index 556bcba2..6c9fe0f9 100644
--- a/src/main/resources/web/index.html
+++ b/src/main/resources/web/index.html
@@ -8,11 +8,29 @@
@@ -67,31 +86,45 @@ Sign in to your network
-
-