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 bd4644c5..93e9abca 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; failed, incomplete, or malformed sub-inspections produce a warning rather than a Healthy claim; disconnected registered nodes produce a warning; unknown Minecraft presence is not treated as offline; VoteLog counts are labeled as logged events; 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 | @@ -312,7 +314,7 @@ values; `generatedAt` is the ISO-8601 string generated by the connector. | `overview` | `pluginVersion`, `platform`, `serverSoftware`, `serverVersion`, configured/enabled vote-site counts, `autoCreateVoteSites`, `processRewards`, `dataStorage`, `voteLoggingEnabled`, `voteLogAvailable`, `voteLogReadable`, proxy mode/method, `votifierDetected`, `configurationHealthy` | | `vote-site-health` | `days`, `voteLoggingEnabled`, `voteLoggingAvailable`, `voteLogReadable`, `autoCreateVoteSites`, `sites`, `unmatchedLoggedServices`, `detectedUnconfiguredServices`, and truncation flags. Site rows always include identity/settings/reward presence and status; logged/immediate/cached counts and last-vote time are present only when VoteLog is readable. Status is `ACTIVE`, `DISABLED`, `SERVICE_SITE_MISSING`, `VOTE_LOG_UNAVAILABLE`, `VOTE_LOG_UNREADABLE`, or `NO_RECENT_VOTES` | | `player` | Either `{found:false, entity:"player"}` or identity/online state, daily/weekly/monthly/all-time totals, points, streaks, `lastVoteTime`, `lastVotes`, `lastVotesTruncated`, and `pendingOfflineVotes` saturated at 100,000. When the storage row is safely available, `storage`, `storageRowAvailable`, bounded allow-listed `columns`, and `columnsTruncated` describe stored VotingPlugin values. Last-vote rows contain `siteKey`, `displayName`, `serviceSite`, and `time`, and include only stored keys that currently resolve as enabled sites | -| `vote-log-summary` | `days`, `total`, `immediate`, `cached`, `uniqueVoters`, top-20 `topServices` and `topServers` count rows | +| `vote-log-summary` | `days`, `total`, `immediate`, `cached`, `uniqueVoters`, top-20 `topServices` (`service`, canonical `count`) and `topServers` (`server`, canonical `count`) rows. During schema-v1 staggered upgrades, new nodes also emit and new dashboards accept the equal legacy `votes` alias at the normalization boundary | | `vote-log-search` | `days`, `limit`, `entries`, `truncated`; each entry has `voteId`, `voteTime`, player UUID/name, service, server, event, context, status, and `cachedTotal` | | `vote-trace` | normalized `voteId`, `found`, chronological `events` using the same entry schema, and `truncated` | | `vote-site-resolution` | requested service/options, `matched`, optional matched-site identity/state, `wouldAutoCreate`, and `sideEffects:false` | diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournal.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournal.java index c01e2328..75d16270 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournal.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournal.java @@ -89,7 +89,7 @@ public synchronized List load() throws IOException { } public synchronized State loadState() throws IOException { - if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new State(List.of(), Map.of()); + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return new State(List.of(), Map.of(), 0L); if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) || Files.size(file) > MAX_BYTES) { throw new IOException("Configuration operation journal is invalid"); @@ -117,15 +117,22 @@ public synchronized State loadState() throws IOException { } result.sort(Comparator.comparing(Entry::createdAt)); Map restartSessions = validateRestartSessions(stored.voteLoggingRestartSessions()); - return new State(List.copyOf(result), restartSessions); + if (stored.configurationGeneration() < 0) throw new IOException("Configuration operation journal is invalid"); + return new State(List.copyOf(result), restartSessions, stored.configurationGeneration()); } public synchronized void save(List entries) throws IOException { - save(entries, Map.of()); + save(entries, Map.of(), 0L); } public synchronized void save(List entries, Map voteLoggingRestartSessions) throws IOException { + save(entries, voteLoggingRestartSessions, 0L); + } + + public synchronized void save(List entries, Map voteLoggingRestartSessions, + long configurationGeneration) throws IOException { if (entries == null) throw new IOException("Configuration operation journal is invalid"); + if (configurationGeneration < 0) throw new IOException("Configuration operation journal is invalid"); Map restartSessions = validateRestartSessions(voteLoggingRestartSessions); Instant cutoff = clock.instant().minus(RETENTION); List filtered = entries.stream().filter(entry -> !entry.createdAt().isBefore(cutoff)) @@ -139,14 +146,16 @@ public synchronized void save(List entries, Map voteLogging throw new IOException("Configuration operation journal is invalid"); } } - byte[] bytes = json.writeValueAsBytes(new JournalFile(SCHEMA_VERSION, retained, restartSessions)); + byte[] bytes = json.writeValueAsBytes(new JournalFile(SCHEMA_VERSION, retained, restartSessions, + configurationGeneration)); while (bytes.length > MAX_BYTES && retained.size() > 1) { int removable = 0; for (int index = 0; index < retained.size(); index++) { if (!isVoteLogging(retained.get(index))) { removable = index; break; } } retained.remove(removable); - bytes = json.writeValueAsBytes(new JournalFile(SCHEMA_VERSION, retained, restartSessions)); + bytes = json.writeValueAsBytes(new JournalFile(SCHEMA_VERSION, retained, restartSessions, + configurationGeneration)); } if (bytes.length > MAX_BYTES) throw new IOException("Configuration operation journal exceeds its bound"); @@ -244,8 +253,13 @@ public record Entry(UUID operationId, String type, Instant createdAt, String dom public record NodeResult(String nodeId, UUID sessionId, boolean complete, Boolean success, String code, String revision, boolean reloaded, boolean rolledBack) { } - public record State(List operations, Map voteLoggingRestartSessions) { } + public record State(List operations, Map voteLoggingRestartSessions, + long configurationGeneration) { + public State(List operations, Map voteLoggingRestartSessions) { + this(operations, voteLoggingRestartSessions, 0L); + } + } private record JournalFile(int schemaVersion, List operations, - Map voteLoggingRestartSessions) { } + Map voteLoggingRestartSessions, long configurationGeneration) { } } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 9b4cf0f3..13adbd85 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -50,6 +50,7 @@ public final class ConfigurationOperations implements AutoCloseable { private final SecureRandom random = new SecureRandom(); private final LinkedHashMap operations = new LinkedHashMap<>(); private final LinkedHashMap voteLoggingRestartSessions = new LinkedHashMap<>(); + private long configurationGeneration; private long retainedChangeBytes; private long retainedMessageBytes; private long retainedFileBytes; @@ -163,7 +164,8 @@ public synchronized OperationListView listView() { List result = new ArrayList<>(operations.values().stream() .skip(Math.max(0, operations.size() - MAX_LISTED_OPERATIONS)).map(this::summaryView).toList()); Collections.reverse(result); - return new OperationListView(List.copyOf(result), Map.copyOf(voteLoggingRestartSessions)); + return new OperationListView(List.copyOf(result), Map.copyOf(voteLoggingRestartSessions), + configurationGeneration); } /** Reissues safe work without repeating nodes that already applied successfully. */ @@ -377,6 +379,7 @@ private void automaticCancellation(StoredOperation operation, String nodeId, UUI long priorChanges = retainedChangeBytes; long priorMessages = retainedMessageBytes; long priorFiles = retainedFileBytes; + long priorConfigurationGeneration = configurationGeneration; LinkedHashMap priorRestartSessions = new LinkedHashMap<>(voteLoggingRestartSessions); try { if (priorResult != null) releaseResultDetails(priorResult); @@ -389,7 +392,8 @@ private void automaticCancellation(StoredOperation operation, String nodeId, UUI audit.append("TASK_CANCELLED", operation.id, nodeId, auditOutcome); } catch (RuntimeException failure) { restoreTransition(operation, nodeId, priorState, priorResult, priorLease, priorAttempt, - priorChanges, priorMessages, priorFiles, priorRestartSessions, failure); + priorChanges, priorMessages, priorFiles, priorConfigurationGeneration, + priorRestartSessions, failure); throw failure; } } @@ -397,6 +401,7 @@ private void automaticCancellation(StoredOperation operation, String nodeId, UUI private void restoreTransition(StoredOperation operation, String nodeId, String priorState, ConfigurationTaskResult priorResult, Instant priorLease, UUID priorAttempt, long priorChanges, long priorMessages, long priorFiles, + long priorConfigurationGeneration, Map priorRestartSessions, RuntimeException failure) { ConfigurationTaskResult current = operation.results.get(nodeId); if (current != null && current != priorResult) releaseResultDetails(current); @@ -407,6 +412,7 @@ private void restoreTransition(StoredOperation operation, String nodeId, String retainedChangeBytes = priorChanges; retainedMessageBytes = priorMessages; retainedFileBytes = priorFiles; + configurationGeneration = priorConfigurationGeneration; voteLoggingRestartSessions.clear(); voteLoggingRestartSessions.putAll(priorRestartSessions); try { @@ -532,9 +538,13 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co long priorChanges = retainedChangeBytes; long priorMessages = retainedMessageBytes; long priorFiles = retainedFileBytes; + long priorConfigurationGeneration = configurationGeneration; LinkedHashMap priorRestartSessions = new LinkedHashMap<>(voteLoggingRestartSessions); try { operation.results.put(nodeId, boundedResult(operation, result)); + if ("APPLY".equals(operation.type) && result.success()) { + configurationGeneration++; + } if (requiresVoteLoggingRestart(operation, result)) { reclaimStaleRestartSessions(nodeId); voteLoggingRestartSessions.remove(nodeId); @@ -547,7 +557,8 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co audit.append("TASK_COMPLETED", operation.id, nodeId, result.success() ? "SUCCESS" : result.code()); } catch (RuntimeException failure) { restoreTransition(operation, nodeId, priorState, priorResult, priorLease, priorAttempt, - priorChanges, priorMessages, priorFiles, priorRestartSessions, failure); + priorChanges, priorMessages, priorFiles, priorConfigurationGeneration, + priorRestartSessions, failure); throw failure; } return view(operation); @@ -786,6 +797,7 @@ private static boolean retryable(StoredOperation operation) { private void restore(ConfigurationOperationJournal.State state) { voteLoggingRestartSessions.putAll(state.voteLoggingRestartSessions()); + configurationGeneration = state.configurationGeneration(); for (ConfigurationOperationJournal.Entry entry : state.operations()) { ManagedConfiguration configuration = switch (entry.domain()) { case ManagedConfiguration.PROXY_ROUTING -> ManagedConfiguration.proxy( @@ -839,7 +851,7 @@ private void persist() { operation.sourceOperationId, List.copyOf(nodes))); } try { - journal.save(entries, voteLoggingRestartSessions); + journal.save(entries, voteLoggingRestartSessions, configurationGeneration); } catch (IOException failure) { throw new IllegalStateException("Could not persist redacted configuration operation history", failure); } @@ -903,7 +915,12 @@ public record OperationView(UUID operationId, String type, String state, Instant Map results, String approvalToken, UUID sourceOperationId, boolean recovered, boolean retryable) { } - public record OperationListView(List items, Map voteLoggingRestartSessions) { } + public record OperationListView(List items, Map voteLoggingRestartSessions, + long configurationGeneration) { + public OperationListView(List items, Map voteLoggingRestartSessions) { + this(items, voteLoggingRestartSessions, 0L); + } + } private record ValidatedTargets(List nodeIds, Map platforms, Map sessions) { } diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index 3c0244e5..d1e8951e 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -16,10 +16,13 @@ --amber: #ffc96b; --amber-soft: #37270d; --danger: #ff89a4; + --topbar-height: 76px; } * { box-sizing: border-box; } [hidden] { display: none !important; } +/* Applies to desktop and the expanded mobile topbar after its dynamic height is measured. */ +[id] { scroll-margin-top: calc(var(--topbar-height) + 20px); } body { margin: 0; min-height: 100vh; background: #07101d radial-gradient(circle at 50% -20%, #17375e 0, transparent 42rem); } button, input, textarea, select { font: inherit; } button { padding: 11px 16px; border: 1px solid #4b9cff; border-radius: 9px; background: #3187ee; color: #fff; font-weight: 750; cursor: pointer; transition: border-color .15s, background .15s, transform .15s; } @@ -28,19 +31,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 +69,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: calc(var(--topbar-height) + 24px); display: flex; max-height: calc(100vh - var(--topbar-height) - 44px); 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); } @@ -210,11 +254,28 @@ li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-to 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; } +/* Keep the authenticated controls usable while the navigation is still a sidebar. */ +@media (max-width: 1360px) and (min-width: 921px) { + .topbar { flex-wrap: wrap; } + .topbar-actions { flex: 1 1 520px; min-width: 0; flex-wrap: wrap; } + .global-search { flex: 1 1 230px; width: auto; min-width: 200px; } + .server-picker { flex: 1 1 280px; min-width: 280px; } +} + @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: var(--topbar-height); 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: var(--topbar-height); 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 +283,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: 480px) { + .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 ba30ba65..1b2500f9 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -15,16 +15,35 @@ 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 topbar = document.querySelector('.topbar'); +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'); @@ -223,6 +242,7 @@ let selectedNodes = new Set(); let selectedServerId = ''; let visibleNodeItems = []; let allNodeItems = []; +let nodePageMetadata = new Map(); let nodeIndex = new Map(); let enrollmentIds = new Set(); let enrollmentsLoaded = false; @@ -249,6 +269,8 @@ let loginInFlight = false; let setupRequired = false; let enrollmentInFlight = false; let enrollmentRefreshRequested = false; +let enrollmentRefreshPromise = null; +let enrollmentRefreshResolve = null; let enrollmentMutationInFlight = false; let configurationOperationsInFlight = 0; let proxyMethodWorkflowInFlight = false; @@ -269,11 +291,53 @@ 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 dashboardInspectionStatus = emptyDashboardInspectionStatus(); +let dashboardTopologySignature = ''; +let dashboardConfigurationGeneration = 0; +let dashboardLoading = false; let operationHistoryItems = []; +let observedServerConfigurationGeneration = null; +let operationHistoryStatus = 'not-loaded'; +let enrollmentStatus = 'not-loaded'; let dedicatedSetupApprovals = new Map(); let pendingDetectedVoteSite = null; let voteLoggingRestartPending = new Map(); let autoLoadInFlight = new Set(); +let autoLoadPending = new Set(); +let nodeLoadInFlight = null; +let nodeLoadQueued = false; +let nodeLoadQueuedPromise = null; +let nodeLoadQueuedResolve = null; +let nodeLoadQueuedReject = null; +let suppressNodeAutoLoad = 0; +let operationHistoryLoadInFlight = null; +let operationHistoryLoadQueued = false; +const appStylesheet = Array.from(document.styleSheets).find(sheet => sheet.href?.endsWith('/app.css')); +const rootStyleRule = appStylesheet + ? Array.from(appStylesheet.cssRules).find(rule => rule.selectorText === ':root') + : null; + +function syncTopbarOffset() { + const topbarBounds = topbar.getBoundingClientRect(); + const searchBounds = globalSearch.hidden ? topbarBounds : globalSearch.getBoundingClientRect(); + const height = Math.ceil(Math.max(topbarBounds.bottom, searchBounds.bottom)); + rootStyleRule?.['style'].setProperty('--topbar-height', `${height}px`); +} + +function scrollToAnchor(target) { + if (!target) return; + syncTopbarOffset(); + target.scrollIntoView({behavior: 'smooth', block: 'start'}); +} + +syncTopbarOffset(); +if (typeof ResizeObserver === 'function') new ResizeObserver(syncTopbarOffset).observe(topbar); +window.addEventListener('resize', syncTopbarOffset); function text(element, value) { element.textContent = value; @@ -579,7 +643,7 @@ function renderSiteHealthResult(value) { updatePluginSuggestions(); setActiveTab('quick-setup', true); text(quickOperationStatus, 'Detected service copied into the VoteSite setup. Load the generated key to confirm it is unused, complete the URL and delay, then preview before creating it.'); - document.querySelector('#quick-setup-card').scrollIntoView({behavior: 'smooth', block: 'start'}); + scrollToAnchor(document.querySelector('#quick-setup-card')); }); actions.append(button); }); @@ -596,15 +660,18 @@ function downloadJson(name, value) { window.setTimeout(() => URL.revokeObjectURL(url), 1000); } -async function runInspection(kind, filters = {}, statusElement = null) { +async function runInspection(kind, filters = {}, statusElement = null, options = {}) { const node = inspectionCapableNode(); if (!node) throw new Error('Choose a connected backend with data inspection support.'); - if (inspectionInFlight) throw new Error('Another read-only inspection is still running.'); + const manageBusy = options.manageBusy !== false; + if (manageBusy && inspectionInFlight) throw new Error('Another read-only inspection is still running.'); const nodeId = node.nodeId; const sessionId = node.sessionId; const requestAuthenticationGeneration = authenticationGeneration; - inspectionInFlight = true; - updateExtendedButtons(); + if (manageBusy) { + inspectionInFlight = true; + updateExtendedButtons(); + } if (statusElement) text(statusElement, `Queued ${kind} inspection…`); try { const boundedFilters = {}; @@ -642,8 +709,10 @@ async function runInspection(kind, filters = {}, statusElement = null) { } return envelope; } finally { - inspectionInFlight = false; - updateExtendedButtons(); + if (manageBusy) { + inspectionInFlight = false; + updateExtendedButtons(); + } } } @@ -909,6 +978,7 @@ function renderOperationHistory() { operationHistory.replaceChildren(); if (operationHistoryItems.length === 0) { text(operationHistory, 'No retained configuration operations.'); + renderMetrics(); return; } operationHistoryItems.forEach(operation => { @@ -982,16 +1052,31 @@ function renderOperationHistory() { item.append(heading, detail); operationHistory.append(item); }); + renderMetrics(); } -async function loadOperationHistory() { +async function loadOperationHistoryOnce() { if (!authenticated) return; + const historyGeneration = authenticationGeneration; + operationHistoryStatus = 'loading'; try { const body = await authorized('/api/v1/operations'); + if (!authenticated || historyGeneration !== authenticationGeneration) return; const retainedOperations = Array.isArray(body.items) ? body.items : []; + const serverConfigurationGeneration = finiteCount(body.configurationGeneration); + const observedSuccessfulApply = serverConfigurationGeneration != null + && (observedServerConfigurationGeneration == null + ? serverConfigurationGeneration > 0 && dashboardLoadedContext === dashboardContext() + : serverConfigurationGeneration > observedServerConfigurationGeneration); + if (serverConfigurationGeneration != null) { + observedServerConfigurationGeneration = observedServerConfigurationGeneration == null + ? serverConfigurationGeneration + : Math.max(observedServerConfigurationGeneration, serverConfigurationGeneration); + } operationHistoryItems = retainedOperations.slice(0, MAX_OPERATION_HISTORY).map(operation => ({...operation, results: Object.fromEntries(Object.entries(operation.results || {}).map(([nodeId, result]) => [nodeId, result ? {...result, configuration: null} : result]))})); + if (observedSuccessfulApply) invalidateConfigurationReads(); const pendingRestarts = new Map(); const restartSessions = body.voteLoggingRestartSessions; if (restartSessions && typeof restartSessions === 'object' && !Array.isArray(restartSessions)) { @@ -1009,11 +1094,36 @@ async function loadOperationHistory() { }); } voteLoggingRestartPending = pendingRestarts; + operationHistoryStatus = 'available'; renderOperationHistory(); updateSetupChecklist(); } catch (error) { + if (!authenticated || historyGeneration !== authenticationGeneration) return; + operationHistoryItems = []; + voteLoggingRestartPending = new Map(); + operationHistoryStatus = 'failed'; text(operationHistory, error.message || 'Operation history could not be loaded.'); + updateSetupChecklist(); + renderMetrics(); + } +} + +function loadOperationHistory() { + if (!authenticated) return Promise.resolve(); + if (operationHistoryLoadInFlight) { + operationHistoryLoadQueued = true; + return operationHistoryLoadInFlight; } + const run = (async () => { + do { + operationHistoryLoadQueued = false; + await loadOperationHistoryOnce(); + } while (operationHistoryLoadQueued && authenticated); + })(); + operationHistoryLoadInFlight = run; + return run.finally(() => { + if (operationHistoryLoadInFlight === run) operationHistoryLoadInFlight = null; + }); } function renderSettingsCatalog() { @@ -1141,7 +1251,17 @@ function applyAuthenticatedSession(body) { lastFileReadOperation = null; lastDiagnostics = null; lastOverview = null; + dashboardOverview = null; + dashboardVoteSiteHealth = null; + dashboardVoteSummary24h = null; + dashboardVoteSummary30d = null; + dashboardLoadedContext = ''; + dashboardInspectionStatus = emptyDashboardInspectionStatus(); + dashboardTopologySignature = ''; operationHistoryItems = []; + observedServerConfigurationGeneration = null; + operationHistoryStatus = 'not-loaded'; + enrollmentStatus = 'not-loaded'; dedicatedSetupApprovals.clear(); voteLoggingRestartPending.clear(); pendingDetectedVoteSite = null; @@ -1155,8 +1275,13 @@ function applyAuthenticatedSession(body) { routingDraftNodeId = ''; configurationFileSelection = configurationFile.value; autoLoadInFlight.clear(); + autoLoadPending.clear(); inputGeneration++; logout.hidden = false; + sidebarToggle.hidden = false; + globalSearch.hidden = false; + syncTopbarOffset(); + headerAction.hidden = false; authCard.hidden = true; welcome.hidden = true; appShell.hidden = false; @@ -1322,14 +1447,70 @@ 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', () => { + setConfigView('compare'); + 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' ? dashboardLoading || inspectionInFlight + : tab === 'servers' ? nodeLoadInFlight != null + : 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) { + if (preset) { + quickPreset.value = preset; + loadedQuickSetup = null; + updateQuickFields(); + clearApprovals(); + } + setActiveTab(tab, true); + if (navigationButton && primaryNavigation.contains(navigationButton)) { + navigationButtons.forEach(button => button.removeAttribute('aria-current')); + navigationButton.setAttribute('aria-current', 'page'); + } + if (scrollTarget) window.requestAnimationFrame(() => scrollToAnchor(document.getElementById(scrollTarget))); +} + function setConfigView(view) { if (!configViewPanels.some(panel => panel.dataset.configPanel === view)) view = 'easy'; configViewButtons.forEach(button => button.classList.toggle('active', button.dataset.configView === view)); @@ -1390,13 +1571,38 @@ function syncFileSelection() { } } +function finishAutoLoad(tab) { + autoLoadInFlight.delete(tab); + if (autoLoadPending.delete(tab)) void autoLoadTab(tab); +} + async function autoLoadTab(tab) { - if (!authenticated || autoLoadInFlight.has(tab)) return; + if (!authenticated) return; + if (autoLoadInFlight.has(tab)) { + autoLoadPending.add(tab); + return; + } + if (tab === 'overview' && inspectionCapableNode() && dashboardLoadedContext !== dashboardContext() + && (dashboardLoading || inspectionInFlight)) { + if (!autoLoadPending.has(tab)) { + autoLoadPending.add(tab); + window.setTimeout(() => { + if (autoLoadPending.delete(tab)) void autoLoadTab(tab); + }, 250); + } + return; + } + if (tab === 'overview' && inspectionCapableNode() && dashboardLoadedContext !== dashboardContext() + && !dashboardLoading && !inspectionInFlight) { + autoLoadInFlight.add(tab); + try { await refreshDashboard(); } finally { finishAutoLoad(tab); } + return; + } if (tab === 'configurations') { const yamlVisible = configViewPanels.some(panel => panel.dataset.configPanel === 'yaml' && !panel.hidden); if (!yamlVisible || configurationDirty || configurationContentPresent || !fileTargetsForSelection().length) return; autoLoadInFlight.add(tab); - try { await loadFileConfiguration(true); } finally { autoLoadInFlight.delete(tab); } + try { await loadFileConfiguration(true); } finally { finishAutoLoad(tab); } return; } if (tab === 'network' && !proxyMethodWorkflowInFlight) { @@ -1404,18 +1610,18 @@ async function autoLoadTab(tab) { try { await Promise.all([proxyMethodCurrentValue ? Promise.resolve() : loadProxyMethod(true), routingDirty || approvedPreview ? Promise.resolve() : loadProxyRouting(true)]); - } finally { autoLoadInFlight.delete(tab); } + } finally { finishAutoLoad(tab); } return; } if (tab === 'quick-setup' && quickPresetReadable() && !loadedQuickSetup && !approvedQuickPreview && !configurationOperationsInFlight) { autoLoadInFlight.add(tab); - try { await loadQuickSetupValues(true); } finally { autoLoadInFlight.delete(tab); } + try { await loadQuickSetupValues(true); } finally { finishAutoLoad(tab); } return; } if (tab === 'data' && inspectionCapableNode() && !inspectionInFlight && !lastOverview) { autoLoadInFlight.add(tab); - try { await refreshOverview(dataOverview); } finally { autoLoadInFlight.delete(tab); } + try { await refreshOverview(dataOverview); } finally { finishAutoLoad(tab); } } } @@ -1445,6 +1651,7 @@ function renderServerPicker() { } } serverPicker.value = selectedServerId; + populateGlobalSearch(); } function renderSelectedServer() { @@ -1542,23 +1749,647 @@ function renderTopology() { } } -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 => +function dashboardContext() { + const node = nodeIndex.get(selectedServerId); + return node ? `${node.nodeId}|${node.sessionId}|${node.online}|${node.acceptedCapabilities.includes('data.inspect.v1')}|${dashboardTopologySignature}|${dashboardConfigurationGeneration}` : ''; +} + +function topologySignature(items) { + return items.filter(isProxy).map(proxy => `${proxy.nodeId}|${proxy.sessionId}|${proxy.online}|${proxy.snapshotSequence}`).join(';'); +} + +function emptyDashboardInspectionStatus() { + return {overview: 'not-loaded', voteSiteHealth: 'not-loaded', voteLog24h: 'not-required', voteLog30d: 'not-required'}; +} + +function dashboardRecord(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} returned malformed data.`); + } + return value; +} + +function boundedDashboardString(value, maximum, allowEmpty = false) { + if (typeof value !== 'string') return {value: '', incomplete: true}; + const normalized = value.trim(); + if (!allowEmpty && !normalized) return {value: '', incomplete: true}; + return {value: normalized.slice(0, maximum), incomplete: normalized.length > maximum}; +} + +function normalizeDashboardCollection(value, maximum, normalize) { + if (!Array.isArray(value)) return {items: [], incomplete: true}; + let incomplete = value.length > maximum; + const items = []; + value.slice(0, maximum).forEach(entry => { + const normalized = normalize(entry); + if (!normalized) incomplete = true; + else { + items.push(normalized.value); + incomplete ||= normalized.incomplete; + } + }); + return {items, incomplete}; +} + +function normalizeDashboardOverview(value) { + const source = dashboardRecord(value, 'Overview inspection'); + const result = {...source}; + let incomplete = false; + ['configuredVoteSites', 'enabledVoteSites'].forEach(field => { + const count = finiteCount(source[field]); + if (count == null || !Number.isSafeInteger(count)) incomplete = true; + result[field] = count; + }); + if (result.configuredVoteSites != null && result.enabledVoteSites != null + && result.enabledVoteSites > result.configuredVoteSites) incomplete = true; + ['autoCreateVoteSites', 'processRewards', 'voteLoggingEnabled', 'voteLogAvailable', 'voteLogReadable', + 'proxyMode', 'votifierDetected', 'configurationHealthy'].forEach(field => { + if (typeof source[field] !== 'boolean') incomplete = true; + result[field] = typeof source[field] === 'boolean' ? source[field] : undefined; + }); + incomplete ||= invalidVoteLoggingState(result); + const requiredStrings = new Set(['pluginVersion', 'platform', 'serverSoftware', 'serverVersion', 'dataStorage']); + [['pluginVersion', 80], ['platform', 32], ['serverSoftware', 80], ['serverVersion', 80], + ['dataStorage', 32], ['proxyMethod', 32]].forEach(([field, maximum]) => { + const normalized = boundedDashboardString(source[field], maximum, field === 'proxyMethod'); + incomplete ||= normalized.incomplete; + if (requiredStrings.has(field) && !normalized.value) incomplete = true; + result[field] = normalized.value; + }); + const platforms = new Set(['BUKKIT']); + const dataStorages = new Set(['SQLITE', 'MYSQL']); + if (!platforms.has(result.platform.toUpperCase()) || !dataStorages.has(result.dataStorage.toUpperCase())) { + incomplete = true; + } + const proxyMethods = new Set(['PLUGINMESSAGING', 'REDIS', 'MQTT', 'MYSQL', 'SOCKETS']); + if (result.proxyMode === true && !proxyMethods.has(result.proxyMethod.toUpperCase())) incomplete = true; + return {result, incomplete}; +} + +function invalidVoteLoggingState(value) { + const available = Object.hasOwn(value, 'voteLogAvailable') ? value.voteLogAvailable : value.voteLoggingAvailable; + return available === true && value.voteLoggingEnabled !== true + || value.voteLogReadable === true && available !== true; +} + +function normalizeDashboardVoteSiteHealth(value, expectedDays = 30) { + const source = dashboardRecord(value, 'Vote Site health inspection'); + const days = finiteCount(source.days); + let incomplete = days == null || days !== expectedDays; + const booleanFields = ['voteLoggingEnabled', 'voteLoggingAvailable', 'voteLogReadable', 'autoCreateVoteSites']; + booleanFields.forEach(field => { if (typeof source[field] !== 'boolean') incomplete = true; }); + incomplete ||= invalidVoteLoggingState(source); + ['truncated', 'detectedUnconfiguredServicesTruncated'].forEach(field => { + if (typeof source[field] !== 'boolean' || source[field] === true) incomplete = true; + }); + const allowedStatuses = new Set(['ACTIVE', 'DISABLED', 'SERVICE_SITE_MISSING', 'VOTE_LOG_UNAVAILABLE', + 'VOTE_LOG_UNREADABLE', 'NO_RECENT_VOTES']); + const voteSiteKeys = new Set(); + const serviceAggregates = new Map(); + const sites = normalizeDashboardCollection(source.sites, 100, entry => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const status = boundedDashboardString(entry.status, 64); + const key = boundedDashboardString(entry.key, 64, true); + const displayName = boundedDashboardString(entry.displayName, 100, true); + const serviceSite = boundedDashboardString(entry.serviceSite, 64, true); + const canonicalKey = key.value.toLowerCase(); + if (status.incomplete || !allowedStatuses.has(status.value) || !key.value || voteSiteKeys.has(canonicalKey) + || typeof entry.enabled !== 'boolean' || typeof entry.hasRewards !== 'boolean' || serviceSite.incomplete) return null; + const expectedStatuses = entry.enabled === false ? new Set(['DISABLED']) : !serviceSite.value + ? new Set(['SERVICE_SITE_MISSING']) : source.voteLoggingAvailable !== true + ? new Set(['VOTE_LOG_UNAVAILABLE']) : source.voteLogReadable !== true + ? new Set(['VOTE_LOG_UNREADABLE']) : new Set(['ACTIVE', 'NO_RECENT_VOTES']); + if (!expectedStatuses.has(status.value)) return null; + const aggregate = source.voteLogReadable === true + ? validDashboardVoteSiteAggregate(entry, status.value) : null; + if (source.voteLogReadable === true && !aggregate) return null; + const serviceIdentity = serviceSite.value.toLowerCase(); + const previousAggregate = serviceAggregates.get(serviceIdentity); + if (previousAggregate && !dashboardVoteSiteAggregatesMatch(previousAggregate, aggregate)) return null; + if (aggregate && serviceIdentity) serviceAggregates.set(serviceIdentity, aggregate); + const aggregateFields = ['loggedVotes', 'immediateVotes', 'cachedVotes', 'lastVoteTime']; + if (source.voteLogReadable !== true + && aggregateFields.some(field => Object.hasOwn(entry, field))) return null; + voteSiteKeys.add(canonicalKey); + return {value: {...entry, status: status.value, key: key.value, displayName: displayName.value, + serviceSite: serviceSite.value, ...(aggregate || {})}, incomplete: key.incomplete || displayName.incomplete}; + }); + const configuredServiceKeys = new Set(sites.items.map(site => site.serviceSite.toLowerCase())); + const detectedServiceKeys = new Set(); + const detected = normalizeDashboardCollection(source.detectedUnconfiguredServices, 100, entry => { + const service = boundedDashboardString(entry, 100); + const identity = service.value.toLowerCase(); + if (service.incomplete || configuredServiceKeys.has(identity) || detectedServiceKeys.has(identity)) return null; + detectedServiceKeys.add(identity); + return {value: service.value, incomplete: false}; + }); + const unmatchedServiceKeys = new Set(); + const unmatched = normalizeDashboardCollection(source.unmatchedLoggedServices, 100, entry => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const service = boundedDashboardString(entry.serviceSite, 64); + const identity = service.value.toLowerCase(); + if (service.incomplete || configuredServiceKeys.has(identity) || unmatchedServiceKeys.has(identity)) return null; + unmatchedServiceKeys.add(identity); + return {value: {...entry, serviceSite: service.value}, incomplete: false}; + }); + incomplete ||= sites.incomplete || detected.incomplete || unmatched.incomplete; + if (source.voteLogReadable !== true && unmatched.items.length > 0) incomplete = true; + const unmatchedItems = source.voteLogReadable === true ? unmatched.items : []; + return {result: {...source, days, ...Object.fromEntries(booleanFields.map(field => + [field, typeof source[field] === 'boolean' ? source[field] : undefined])), sites: sites.items, + detectedUnconfiguredServices: detected.items, unmatchedLoggedServices: unmatchedItems}, incomplete}; +} + +function validDashboardVoteSiteAggregate(entry, status) { + const loggedVotes = finiteCount(entry.loggedVotes); + const immediateVotes = finiteCount(entry.immediateVotes); + const cachedVotes = finiteCount(entry.cachedVotes); + const lastVoteTime = finiteCount(entry.lastVoteTime); + if (loggedVotes == null || immediateVotes == null || cachedVotes == null || lastVoteTime == null + || immediateVotes + cachedVotes !== loggedVotes) return null; + if (loggedVotes === 0 ? lastVoteTime !== 0 : lastVoteTime === 0) return null; + if (status === 'ACTIVE' && loggedVotes === 0 || status === 'NO_RECENT_VOTES' && loggedVotes !== 0) return null; + return {loggedVotes, immediateVotes, cachedVotes, lastVoteTime}; +} + +function dashboardVoteSiteAggregatesMatch(left, right) { + return left.loggedVotes === right.loggedVotes && left.immediateVotes === right.immediateVotes + && left.cachedVotes === right.cachedVotes && left.lastVoteTime === right.lastVoteTime; +} + +function dashboardHealthContradictsOverview(overview, health) { + if (!overview || !health) return false; + const flagsMatch = health.autoCreateVoteSites === overview.autoCreateVoteSites + && health.voteLoggingEnabled === overview.voteLoggingEnabled + && health.voteLoggingAvailable === overview.voteLogAvailable + && health.voteLogReadable === overview.voteLogReadable; + const configured = finiteCount(overview.configuredVoteSites); + const siteCountMatches = health.truncated === true || configured == null + || Array.isArray(health.sites) && health.sites.length === configured; + const enabled = finiteCount(overview.enabledVoteSites); + const enabledSiteCountMatches = health.truncated === true || enabled == null + || Array.isArray(health.sites) && health.sites.filter(site => site.enabled === true).length === enabled; + return !flagsMatch || !siteCountMatches || !enabledSiteCountMatches; +} + +function dashboardHealthContradictsVoteSummary(health, summary) { + if (!health || !summary || health.voteLogReadable !== true || !Array.isArray(health.sites)) return false; + if (dashboardHealthServicesContradictSummary(health.sites, summary.topServices)) return true; + const rowContradiction = health.sites.some(site => { + if (!site || typeof site !== 'object' || Array.isArray(site)) return false; + return [['loggedVotes', 'total'], ['immediateVotes', 'immediate'], ['cachedVotes', 'cached']] + .some(([siteField, summaryField]) => { + const siteCount = finiteCount(site[siteField]); + const summaryCount = finiteCount(summary[summaryField]); + return siteCount != null && summaryCount != null && siteCount > summaryCount; + }); + }); + if (rowContradiction) return true; + return [['loggedVotes', 'total'], ['immediateVotes', 'immediate'], ['cachedVotes', 'cached']] + .some(([siteField, summaryField]) => { + const summaryCount = finiteCount(summary[summaryField]); + return summaryCount != null && dashboardHealthAggregateExceedsSummary( + health.sites, siteField, summaryCount); + }); +} + +function dashboardHealthServicesContradictSummary(sites, topServices) { + if (!Array.isArray(sites) || !Array.isArray(topServices)) return false; + const topServicesComplete = topServices.length < 20; + const topServiceCounts = new Map(); + topServices.forEach(entry => { + const identity = dashboardCountRowIdentity(entry, 'service'); + const count = finiteCount(entry?.count); + if (identity != null && count != null) topServiceCounts.set(identity, count); + }); + const countedServices = new Set(); + return sites.some(site => { + const serviceIdentity = typeof site?.serviceSite === 'string' + ? site.serviceSite.trim().toLowerCase() : ''; + const loggedVotes = finiteCount(site?.loggedVotes); + if (!serviceIdentity || loggedVotes == null) return false; + if (countedServices.has(serviceIdentity)) { + // A 64-character ServiceSite may be a truncated identity. Preserve the + // existing fail-closed behavior instead of silently merging aliases. + return serviceIdentity.length >= 64; + } + countedServices.add(serviceIdentity); + // A complete ranking intentionally omits services with no votes. Only a + // positive health aggregate can contradict that omission. + if (!topServiceCounts.has(serviceIdentity)) return loggedVotes > 0 && topServicesComplete; + return loggedVotes > topServiceCounts.get(serviceIdentity); + }); +} + +function dashboardHealthAggregateExceedsSummary(sites, siteField, summaryCount) { + let aggregate = 0; + const countedServices = new Set(); + return sites.some(site => { + const serviceIdentity = typeof site?.serviceSite === 'string' ? site.serviceSite.trim().toLowerCase() : ''; + if (serviceIdentity && countedServices.has(serviceIdentity)) { + // The node bounds serialized ServiceSite values to 64 characters. A + // duplicate at that boundary may represent two distinct truncated names, + // so treat the aggregate as unchecked instead of silently deduplicating it. + return serviceIdentity.length >= 64; + } + const siteCount = finiteCount(site?.[siteField]); + if (siteCount == null) return false; + if (serviceIdentity) countedServices.add(serviceIdentity); + aggregate += siteCount; + return aggregate > summaryCount; + }); +} + +function normalizeDashboardCountRows(value, maximum, label) { + const identities = new Set(); + return normalizeDashboardCollection(value, maximum, entry => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const name = boundedDashboardString(entry[label], 64, false); + const hasCount = Object.hasOwn(entry, 'count'); + const hasVotes = Object.hasOwn(entry, 'votes'); + const count = hasCount ? finiteCount(entry.count) : hasVotes ? finiteCount(entry.votes) : null; + const legacyCount = hasVotes ? finiteCount(entry.votes) : null; + if (name.incomplete || count == null || hasVotes && legacyCount == null + || hasCount && hasVotes && count !== legacyCount) return null; + const identity = name.value.toLowerCase(); + if (identities.has(identity)) return null; + identities.add(identity); + return {value: {...entry, [label]: name.value, count}, incomplete: false}; + }); +} + +function normalizeDashboardVoteSummary(value, expectedDays = 30) { + const source = dashboardRecord(value, 'VoteLog summary inspection'); + const days = finiteCount(source.days); + const total = finiteCount(source.total); + const immediate = finiteCount(source.immediate); + const cached = finiteCount(source.cached); + const uniqueVoters = finiteCount(source.uniqueVoters); + let incomplete = days == null || days !== expectedDays || total == null || immediate == null + || cached == null || uniqueVoters == null; + const services = normalizeDashboardCountRows(source.topServices, 20, 'service'); + const servers = normalizeDashboardCountRows(source.topServers, 20, 'server'); + incomplete ||= services.incomplete || servers.incomplete + || !countRowsAreNonIncreasing(services.items) || !countRowsAreNonIncreasing(servers.items); + incomplete ||= !countRowsArePositive(services.items) || !countRowsArePositive(servers.items); + if (total != null) { + incomplete ||= immediate == null || cached == null || immediate + cached !== total + || uniqueVoters == null || total > 0 && uniqueVoters <= 0 || uniqueVoters > total; + incomplete ||= total > 0 && (services.items.length === 0 || servers.items.length === 0); + incomplete ||= countRowsExceedTotal(services.items, total) + || countRowsExceedTotal(servers.items, total) + || !countRowsSumMatchesTotal(services.items, total) + || !countRowsSumMatchesTotal(servers.items, total); + } + return {result: {...source, days, total, immediate, cached, uniqueVoters, + topServices: services.items, topServers: servers.items}, incomplete}; +} + +function countRowsExceedTotal(items, total) { + let remaining = total; + for (const entry of items) { + if (entry.count > remaining) return true; + remaining -= entry.count; + } + return false; +} + +function countRowsSumMatchesTotal(items, total) { + return items.length >= 20 || items.reduce((sum, entry) => sum + entry.count, 0) === total; +} + +function countRowsAreNonIncreasing(items) { + for (let index = 1; index < items.length; index++) { + if (items[index].count > items[index - 1].count) return false; + } + return true; +} + +function countRowsArePositive(items) { + return items.every(entry => entry.count > 0); +} + +function dashboardCountRowIdentity(entry, label) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const name = boundedDashboardString(entry[label], 100, false); + return name.incomplete ? null : name.value.toLowerCase(); +} + +function dashboardCountRowsContradict(shortRows, longRows, label) { + if (!Array.isArray(shortRows) || !Array.isArray(longRows)) return false; + const widerWindowIsComplete = longRows.length < 20; + const longCounts = new Map(); + longRows.forEach(entry => { + const identity = dashboardCountRowIdentity(entry, label); + const count = finiteCount(entry?.count); + if (identity != null && count != null) longCounts.set(identity, count); + }); + return shortRows.some(entry => { + const identity = dashboardCountRowIdentity(entry, label); + const shortCount = finiteCount(entry?.count); + if (identity == null || shortCount == null) return false; + if (!longCounts.has(identity)) return widerWindowIsComplete; + return shortCount > longCounts.get(identity); + }); +} + +function dashboardVoteSummariesContradict(shortWindow, longWindow) { + const scalarContradiction = ['total', 'immediate', 'cached', 'uniqueVoters'].some(field => + finiteCount(shortWindow?.[field]) != null && finiteCount(longWindow?.[field]) != null + && shortWindow[field] > longWindow[field]); + return scalarContradiction + || dashboardCountRowsContradict(shortWindow?.topServices, longWindow?.topServices, 'service') + || dashboardCountRowsContradict(shortWindow?.topServers, longWindow?.topServers, 'server'); +} + +function issue(severity, title, detail, action, tab, scrollTarget = '', preset = '', onAction = null) { + return {severity, title, detail, action, tab, scrollTarget, preset, onAction}; +} + +function dashboardIssues() { + const summary = {items: [], total: 0, actionable: 0, hasCritical: false, hasWarning: false}; + const issues = {push(item) { + summary.total += 1; + if (item.severity !== 'informational') summary.actionable += 1; + summary.hasCritical ||= item.severity === 'critical'; + summary.hasWarning ||= item.severity === 'warning'; + if (summary.items.length < 30) summary.items.push(item); + }}; + 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('warning', `${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 (dashboardLoadedContext === dashboardContext() || Object.values(dashboardInspectionStatus) + .some(status => ['failed', 'incomplete'].includes(status))) { + const labels = {overview: 'Overview', voteSiteHealth: 'Vote Site health', + voteLog24h: '24-hour VoteLog summary', voteLog30d: '30-day VoteLog summary'}; + Object.entries(dashboardInspectionStatus).forEach(([inspection, status]) => { + if (!['failed', 'incomplete'].includes(status)) return; + issues.push(issue('warning', `${labels[inspection]} is ${status === 'failed' ? 'unavailable' : 'incomplete'}`, + 'Some dashboard checks could not be verified, so this server is not reported as fully healthy.', + '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.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')); + }); + const reportedBackends = new Map(); + 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); + if (!backend || typeof backend.backendId !== 'string' || !backend.backendId) return; + const current = reportedBackends.get(backend.backendId) || { + backend, proxies: new Set() + }; + current.proxies.add(proxy.displayName); + if (backend.presenceKnown && !backend.available) { + issues.push(issue('warning', `${backend.displayName} is unavailable to ${proxy.displayName}`, + `Reported unavailable by ${proxy.displayName}.`, 'Test communication', 'network', 'transport-test-card')); + } + reportedBackends.set(backend.backendId, current); + }); + }); + reportedBackends.forEach(({backend, proxies}) => { + const proxyNames = [...proxies].join(', '); + const registered = nodeIndex.get(backend.backendId); + const registeredBukkitBackend = registered?.platform === 'BUKKIT'; + if (!registeredBukkitBackend) issues.push(issue('warning', `${backend.displayName} is not registered as a Bukkit backend`, + `Reported by ${proxyNames}; Control has no current Bukkit backend record.`, 'View servers', 'servers')); + if (enrollmentsLoaded && registeredBukkitBackend && !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 (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 (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')); + else if (dashboardOverview.enabledVoteSites === 0) issues.push(issue('warning', 'All Vote Sites are disabled', + 'Enable at least one configured Vote Site before expecting votes.', 'Open Vote Sites', 'data', 'site-health-card')); + 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').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.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.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)) + .forEach(operation => issues.push(issue('warning', `${operationLabel(operation)} needs review`, + operationPhase(operation), 'View operation', 'activity'))); + if (operationHistoryStatus === 'failed') issues.push(issue('warning', 'Operation history is unavailable', + 'Recent configuration failures could not be loaded, so dashboard health is incomplete.', + 'Retry activity', 'activity', '', '', () => { + openWorkspace('activity'); + return loadOperationHistory(); })); - 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); + if (enrollmentStatus === 'failed') issues.push(issue('warning', 'Enrollment state is unavailable', + 'Recent enrollment state could not be loaded, so backend access health is incomplete.', + 'Retry access', 'access', '', '', () => { + openWorkspace('access'); + return loadEnrollments(); + })); + return summary; +} + +function renderAttention(issues, total = issues.length) { + 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', () => { + if (item.onAction) void item.onAction(); + else openWorkspace(item.tab, item.scrollTarget, item.preset); + }); + row.append(indicator, copy, action); + attentionFeed.append(row); + }); + if (total > 8) attentionFeed.append(text(document.createElement('small'), `${total - 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 => service.count), 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 = service.count; + track.setAttribute('aria-label', `${service.service || 'Unknown service'} logged votes`); + row.append(track, text(document.createElement('span'), service.count)); + 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 targetCount = Object.keys(operation.nodeStates || {}).length || results.length; + 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}/${targetCount} targets successful · ${when}`)); + overviewActivity.append(item); + }); +} + +function renderMetrics() { + const issueSummary = dashboardIssues(); + const issues = issueSummary.items; + const selected = nodeIndex.get(selectedServerId); + const current = dashboardLoadedContext === dashboardContext(); + const hasCritical = issueSummary.hasCritical; + const hasWarning = issueSummary.hasWarning; + const hasIncompleteInspection = Object.values(dashboardInspectionStatus) + .some(status => ['failed', 'incomplete'].includes(status)); + const state = !selected ? 'Unavailable' : hasCritical ? 'Critical' + : !selected.online || !current || hasWarning || hasIncompleteInspection ? 'Warning' : 'Healthy'; + text(metricHealth, state); + text(metricHealthDetail, !selected ? 'Choose a server' : !selected.online ? 'Control disconnected' + : !current ? 'Inspection not loaded' : hasCritical ? 'Immediate attention required' + : hasIncompleteInspection ? 'Dashboard data incomplete' + : 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 siteCountsKnown = configured != null && enabled != null; + const allConfiguredSitesDisabled = siteCountsKnown && configured > 0 && enabled === 0; + const siteWarnings = current && Array.isArray(dashboardVoteSiteHealth?.sites) + ? dashboardVoteSiteHealth.sites.filter(site => site.status === 'SERVICE_SITE_MISSING').length + + dashboardVoteSiteHealth.detectedUnconfiguredServices.length + + dashboardVoteSiteHealth.unmatchedLoggedServices.length + + (allConfiguredSitesDisabled ? 1 : 0) : null; + text(metricVoteSites, !siteCountsKnown ? '—' : `${enabled}/${configured}`); + text(metricVoteSitesDetail, !siteCountsKnown ? 'Counts unavailable' + : `${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 = issueSummary.actionable; + text(metricIssues, actionable); + text(metricIssuesDetail, actionable ? `${actionable} actionable` : 'No observed problems'); + renderAttention(issues, issueSummary.total); + renderQuickActions(); + renderVoteActivity(); + renderOverviewActivity(); +} + +function finiteCount(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; } function syncSourceCandidates() { @@ -1771,7 +2602,7 @@ function updateSetupChecklist(overview = lastOverview) { const states = [ Boolean(node?.online && isBackend(node)), Boolean(overview && typeof overview.proxyMode === 'boolean'), - Boolean(overview && Number.isFinite(Number(overview.configuredVoteSites))), + Boolean(overview && Number.isSafeInteger(overview.configuredVoteSites) && overview.configuredVoteSites >= 0), Boolean(overview?.processRewards), Boolean(overview?.dataStorage && !loggingRestartPending && (!overview.voteLoggingEnabled || overview.voteLogReadable === true)), @@ -1804,6 +2635,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 = !authenticated || inspectionInFlight || dashboardLoading; runNetworkDoctor.disabled = !inspectionReady; downloadNetworkDiagnostics.disabled = !lastDiagnostics; refreshSetupChecklist.disabled = !inspectionReady; @@ -1837,6 +2669,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(); } @@ -1857,6 +2690,21 @@ function renderNodeViews() { updateExtendedButtons(); } +function selectNodePage(offset) { + pageOffset = Math.max(0, offset); + visibleNodeItems = allNodeItems.slice(pageOffset, pageOffset + PAGE_SIZE); + const first = visibleNodeItems.length === 0 ? 0 : pageOffset + 1; + const last = pageOffset + visibleNodeItems.length; + const pageMeta = nodePageMetadata.get(pageOffset); + const backendLimit = pageMeta?.backendItemsTruncated + ? ` Backend summaries are limited to ${pageMeta.backendItemsReturned} entries on this page.` : ''; + text(message, visibleNodeItems.length === 0 ? 'No nodes on this page.' + : `Showing nodes ${first}–${last}.${backendLimit}`); + text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`); + previousPage.disabled = pageOffset === 0; + nextPage.disabled = pageOffset + visibleNodeItems.length >= allNodeItems.length; +} + function routingDraftStatus(status) { return routingDirty ? `${status} Your unsaved proxy-routing draft is retained for ${routingDraftNodeId || 'the previous server'}; explicitly switch servers or load current values to discard it.` @@ -1898,6 +2746,13 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) { lastFileReadOperation = null; lastDiagnostics = null; lastOverview = null; + dashboardOverview = null; + dashboardVoteSiteHealth = null; + dashboardVoteSummary24h = null; + dashboardVoteSummary30d = null; + dashboardLoadedContext = ''; + dashboardInspectionStatus = emptyDashboardInspectionStatus(); + dashboardTopologySignature = ''; downloadNetworkDiagnostics.disabled = true; voteSitesSourceId = ''; voteSitesTargetIds.clear(); @@ -1928,10 +2783,15 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) { quickPreset.value = preset; updateQuickFields(); text(quickOperationStatus, reason); + renderMetrics(); } function selectPrimaryServer(nodeId) { if (nodeId && !nodeIndex.has(nodeId)) return; + if (nodeId && nodeId === selectedServerId) { + serverPicker.value = selectedServerId; + return; + } if (nodeId !== selectedServerId && !confirmDiscardUnsavedConfiguration('switching servers')) { serverPicker.value = selectedServerId; return; @@ -1943,6 +2803,7 @@ function selectPrimaryServer(nodeId) { resetServerContextValues('Server changed. Load current values before continuing.'); updatePluginSuggestions(); renderNodeViews(); + void autoLoadTab(tabFromHash()); } function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 || proxyMethodWorkflowInFlight) { @@ -2110,6 +2971,13 @@ function discardAuthenticationState(reason) { loadedQuickSetup = null; inputGeneration++; logout.hidden = true; + sidebarToggle.hidden = true; + globalSearch.hidden = true; + syncTopbarOffset(); + globalSearchInput.value = ''; + globalSearchOptions.replaceChildren(); + headerAction.hidden = true; + closeSidebar(); appShell.hidden = true; serverPickerLabel.hidden = true; welcome.hidden = false; @@ -2132,7 +3000,17 @@ function discardAuthenticationState(reason) { lastFileReadOperation = null; lastDiagnostics = null; lastOverview = null; + dashboardOverview = null; + dashboardVoteSiteHealth = null; + dashboardVoteSummary24h = null; + dashboardVoteSummary30d = null; + dashboardLoadedContext = ''; + dashboardInspectionStatus = emptyDashboardInspectionStatus(); + dashboardTopologySignature = ''; operationHistoryItems = []; + observedServerConfigurationGeneration = null; + operationHistoryStatus = 'not-loaded'; + enrollmentStatus = 'not-loaded'; dedicatedSetupApprovals.clear(); voteLoggingRestartPending.clear(); pendingDetectedVoteSite = null; @@ -2157,6 +3035,7 @@ function discardAuthenticationState(reason) { routingDirty = false; routingDraftNodeId = ''; autoLoadInFlight.clear(); + autoLoadPending.clear(); quickSetupForm.reset(); resetDedicatedSetupValues(); rewardSimulationForm.reset(); @@ -2288,6 +3167,23 @@ function operationContextCurrent(context) { && context.selectedSessionId === nodeIndex.get(context.selectedServerId)?.sessionId; } +function invalidateConfigurationReads() { + fileReadCache.clear(); + lastFileReadOperation = null; + clearApprovals(); + loadedQuickSetup = null; + if (!configurationDirty) { + configurationContent.value = ''; + configurationContentPresent = false; + text(fileOperationStatus, 'Configuration changed; read the current file before previewing changes.'); + } + lastOverview = null; + lastDiagnostics = null; + dashboardConfigurationGeneration++; + invalidateDashboardInspection(); + updateExtendedButtons(); +} + async function waitForOperation(operation, statusElement = operationStatus, context = operationContext()) { if (operationContextCurrent(context)) text(statusElement, operationSummary(operation)); rememberOperation(operation); @@ -2302,21 +3198,21 @@ async function waitForOperation(operation, statusElement = operationStatus, cont text(statusElement, `${operationSummary(operation)}\nThe apply completed, but newer unsaved proxy-routing edits remain. Preview again before applying them.`); } rememberVoteLoggingRestart(operation); - if (operation.type === 'APPLY' && Object.values(operation.results || {}).some(result => result?.success)) { - fileReadCache.clear(); - lastFileReadOperation = null; - } - if (operationContextCurrent(context) && operation.type === 'APPLY' - && Object.values(operation.results || {}).some(result => result?.success)) { - lastOverview = null; - lastDiagnostics = null; - updateExtendedButtons(); + const applied = operation.type === 'APPLY' + && Object.values(operation.results || {}).some(result => result?.success); + if (applied) { + invalidateConfigurationReads(); + if (tabFromHash() === 'overview') { + text(dataOverview, 'Configuration changed; refreshing server overview…'); + window.setTimeout(() => void autoLoadTab('overview'), 0); + } } return operation; } async function startConfigurationOperation(path, body, statusElement = operationStatus) { - if (path.endsWith('/apply')) { + const applyOperation = path.endsWith('/apply'); + if (applyOperation) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; @@ -2342,14 +3238,21 @@ async function loadEnrollments() { if (!authenticated) return; if (enrollmentInFlight) { enrollmentRefreshRequested = true; - return; + if (!enrollmentRefreshPromise) { + enrollmentRefreshPromise = new Promise(resolve => { enrollmentRefreshResolve = resolve; }); + } + return enrollmentRefreshPromise; } enrollmentInFlight = true; + const enrollmentGeneration = authenticationGeneration; + enrollmentStatus = 'loading'; refreshEnrollments.disabled = true; try { const body = await authorized('/api/v1/enrollments'); + if (!authenticated || enrollmentGeneration !== authenticationGeneration) return; enrollmentIds = new Set(Array.isArray(body.nodeIds) ? body.nodeIds : []); enrollmentsLoaded = true; + enrollmentStatus = 'available'; if (allNodeItems.length) renderNodeViews(); enrollmentList.replaceChildren(); if (!Array.isArray(body.nodeIds) || body.nodeIds.length === 0) { @@ -2383,7 +3286,9 @@ async function loadEnrollments() { enrollmentList.append(item); }); } catch (error) { + if (!authenticated || enrollmentGeneration !== authenticationGeneration) return; enrollmentsLoaded = false; + enrollmentStatus = 'failed'; if (allNodeItems.length) renderNodeViews(); text(enrollmentMessage, error.message || 'Enrollments could not be loaded.'); } finally { @@ -2392,6 +3297,10 @@ async function loadEnrollments() { const refreshAgain = enrollmentRefreshRequested && authenticated; enrollmentRefreshRequested = false; if (refreshAgain) await loadEnrollments(); + const resolveRefresh = enrollmentRefreshResolve; + enrollmentRefreshPromise = null; + enrollmentRefreshResolve = null; + if (resolveRefresh) resolveRefresh(); } } @@ -2437,7 +3346,7 @@ async function loadAllNodes() { throw new Error('Node registry could not be loaded consistently. Try refreshing again.'); } -async function loadNodes() { +async function loadNodesOnce() { if (!authenticated) return; refresh.disabled = true; previousPage.disabled = true; @@ -2446,8 +3355,10 @@ async function loadNodes() { try { const registry = await loadAllNodes(); const previousNodeIndex = nodeIndex; - visibleNodeItems = registry.items.slice(pageOffset, pageOffset + PAGE_SIZE); allNodeItems = registry.items; + nodePageMetadata = registry.pageMetadata; + selectNodePage(pageOffset); + dashboardTopologySignature = topologySignature(registry.items); backendTopologyTruncated = registry.truncated; backendTopologyTruncatedNodeIds = registry.truncatedNodeIds; nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); @@ -2474,6 +3385,12 @@ async function loadNodes() { dedicatedSetupApprovals.clear(); lastOverview = null; lastDiagnostics = null; + dashboardOverview = null; + dashboardVoteSiteHealth = null; + dashboardVoteSummary24h = null; + dashboardVoteSummary30d = null; + dashboardLoadedContext = ''; + dashboardInspectionStatus = emptyDashboardInspectionStatus(); inputGeneration++; text(operationStatus, routingDraftStatus('A selected node changed capabilities during refresh. Preview again before apply.')); } @@ -2522,20 +3439,11 @@ async function loadNodes() { renderNodeViews(); updatePluginSuggestions(); updateConfigurationButtons(); - const first = visibleNodeItems.length === 0 ? 0 : pageOffset + 1; - const last = pageOffset + visibleNodeItems.length; - const pageMeta = registry.pageMetadata.get(pageOffset); - const backendLimit = pageMeta?.backendItemsTruncated - ? ` Backend summaries are limited to ${pageMeta.backendItemsReturned} entries on this page.` : ''; - text(message, visibleNodeItems.length === 0 ? 'No nodes on this page.' - : `Showing nodes ${first}–${last}.${backendLimit}`); - text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`); - previousPage.disabled = pageOffset === 0; - nextPage.disabled = pageOffset + visibleNodeItems.length >= registry.items.length; - void autoLoadTab(tabFromHash()); + if (suppressNodeAutoLoad === 0) void autoLoadTab(tabFromHash()); } catch (error) { visibleNodeItems = []; allNodeItems = []; + nodePageMetadata = new Map(); backendTopologyTruncated = false; nodeIndex.clear(); nodeCapabilities.clear(); @@ -2554,6 +3462,37 @@ async function loadNodes() { } } +async function loadNodes() { + if (nodeLoadInFlight) { + nodeLoadQueued = true; + if (!nodeLoadQueuedPromise) { + nodeLoadQueuedPromise = new Promise((resolve, reject) => { + nodeLoadQueuedResolve = resolve; + nodeLoadQueuedReject = reject; + }); + } + return nodeLoadQueuedPromise; + } + const load = loadNodesOnce(); + nodeLoadInFlight = load; + updateHeaderAction(tabFromHash()); + try { + return await load; + } finally { + if (nodeLoadInFlight === load) nodeLoadInFlight = null; + updateHeaderAction(tabFromHash()); + if (nodeLoadQueued) { + nodeLoadQueued = false; + const resolveQueued = nodeLoadQueuedResolve; + const rejectQueued = nodeLoadQueuedReject; + nodeLoadQueuedPromise = null; + nodeLoadQueuedResolve = null; + nodeLoadQueuedReject = null; + loadNodes().then(resolveQueued, rejectQueued); + } + } +} + form.addEventListener('submit', async event => { event.preventDefault(); if (loginInFlight) return; @@ -2724,12 +3663,13 @@ applyConfiguration.addEventListener('click', async () => { if (!approvedPreview || !window.confirm('Apply this exact preview to every selected proxy? Each node may still reject a stale revision.')) return; const approval = approvedPreview; approvedPreview = null; - const applyGeneration = inputGeneration + 1; + const submittedProposal = JSON.stringify({proposal: proposal(), nodeIds: approval.nodeIds}); try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }); - if (operation.state === 'SUCCEEDED' && applyGeneration === inputGeneration) { + const currentProposal = JSON.stringify({proposal: proposal(), nodeIds: targets('config.proxy-routing.v1')}); + if (operation.state === 'SUCCEEDED' && submittedProposal === currentProposal) { routingDirty = false; routingDraftNodeId = ''; } @@ -2847,13 +3787,17 @@ applyFileConfiguration.addEventListener('click', async () => { || !window.confirm(`Apply this exact ${configurationFile.value} preview to ${fileTargetDescription()}?`)) return; const approval = approvedFilePreview; approvedFilePreview = null; - const applyGeneration = inputGeneration + 1; + const submittedFile = JSON.stringify({content: configurationContent.value, fileName: approval.fileName, + nodeIds: approval.nodeIds, sessions: approval.nodeIds.map(nodeId => approval.sessions.get(nodeId))}); try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, fileOperationStatus); - text(fileOperationStatus, operationSummary(operation)); - if (operation.state === 'SUCCEEDED' && applyGeneration === inputGeneration) { + const currentFileTargets = fileTargetsForSelection(configurationFile.value); + const currentFile = JSON.stringify({content: configurationContent.value, fileName: configurationFile.value, + nodeIds: currentFileTargets, sessions: currentFileTargets.map(nodeId => nodeIndex.get(nodeId)?.sessionId)}); + if (operation.state === 'SUCCEEDED' && submittedFile === currentFile) { + text(fileOperationStatus, operationSummary(operation)); fileReadCache.clear(); lastFileReadOperation = null; configurationDirty = false; @@ -2861,6 +3805,10 @@ applyFileConfiguration.addEventListener('click', async () => { configurationDraftSessionId = ''; configurationDraftFileName = ''; updateExtendedButtons(); + } else if (operation.state === 'SUCCEEDED') { + text(fileOperationStatus, `${operationSummary(operation)}\nThe apply completed, but newer unsaved file edits remain. Preview again before applying them.`); + } else { + text(fileOperationStatus, operationSummary(operation)); } } catch (error) { text(fileOperationStatus, error.message); } }); @@ -3031,12 +3979,18 @@ applyQuickSetup.addEventListener('click', async () => { if (!approvedQuickPreview || !window.confirm(confirmation)) return; const approval = approvedQuickPreview; approvedQuickPreview = null; - inputGeneration++; + const submittedQuickSetup = JSON.stringify({preset: quickPreset.value, options: quickOptions(), + nodeIds: approval.nodeIds, sourceId: approval.sourceId || ''}); try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, quickOperationStatus); - text(quickOperationStatus, operationSummary(operation)); + const currentNodeIds = sync ? selectedVoteSitesTargets() : targets('config.quick-setup.v1'); + const currentQuickSetup = JSON.stringify({preset: quickPreset.value, options: quickOptions(), + nodeIds: currentNodeIds, sourceId: sync ? voteSitesSourceId : ''}); + text(quickOperationStatus, operation.state === 'SUCCEEDED' && submittedQuickSetup !== currentQuickSetup + ? `${operationSummary(operation)}\nThe apply completed, but newer guided setup edits remain. Preview again before applying them.` + : operationSummary(operation)); } catch (error) { text(quickOperationStatus, error.message); } }); @@ -3226,6 +4180,7 @@ async function loadDedicatedSetup(preset) { async function previewDedicatedSetup(preset) { dedicatedSetupApprovals.delete(preset); const elements = dedicatedSetupElements(preset); + const previewGeneration = inputGeneration; try { const nodeIds = backendQuickTargets(); const options = dedicatedSetupOptions(preset); @@ -3233,7 +4188,8 @@ async function previewDedicatedSetup(preset) { const operation = await startConfigurationOperation('/api/v1/configuration/preview', { nodeIds, configuration: {domain: 'quick-setup', preset, options} }, elements.status); - if (signature !== JSON.stringify({nodeIds: backendQuickTargets(), options: dedicatedSetupOptions(preset)})) { + if (previewGeneration !== inputGeneration + || signature !== JSON.stringify({nodeIds: backendQuickTargets(), options: dedicatedSetupOptions(preset)})) { text(elements.status, 'The target scope or setup value changed while previewing. Preview again.'); } else if (operation.state === 'SUCCEEDED' && operation.approvalToken) { dedicatedSetupApprovals.set(preset, {operationId: operation.operationId, @@ -3250,11 +4206,16 @@ async function applyDedicatedSetup(preset) { if (!approval || !window.confirm(`Apply the exact ${preset} preview to every selected Bukkit node?${restart}`)) return; dedicatedSetupApprovals.delete(preset); const elements = dedicatedSetupElements(preset); + const submittedOptions = JSON.stringify(dedicatedSetupOptions(preset)); try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, elements.status); - if (operation.state === 'SUCCEEDED') { + const currentTargets = backendQuickTargets(); + const inputsCurrent = submittedOptions === JSON.stringify(dedicatedSetupOptions(preset)) + && currentTargets.length === approval.nodeIds.length + && approval.nodeIds.every(nodeId => currentTargets.includes(nodeId)); + if (operation.state === 'SUCCEEDED' && inputsCurrent) { fileReadCache.clear(); if (preset === 'auto-create-vote-sites') { text(elements.state, autoSitesEnabled.checked ? 'Enabled on selected' : 'Disabled on selected'); @@ -3264,6 +4225,8 @@ async function applyDedicatedSetup(preset) { elements.state.className = 'pill neutral'; } lastOverview = null; + } else if (operation.state === 'SUCCEEDED') { + text(elements.status, `${operationSummary(operation)}\nThe apply completed, but newer setup edits remain. Preview again before applying them.`); } } catch (error) { text(elements.status, error.message); } updateExtendedButtons(); @@ -3296,11 +4259,23 @@ applyVoteLogging.addEventListener('click', () => applyDedicatedSetup('vote-loggi [autoSitesEnabled, voteLoggingEnabled, voteLoggingDays, voteLoggingMainMysql].forEach(field => { field.addEventListener('input', () => { dedicatedSetupApprovals.delete(field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging'); + inputGeneration++; updateExtendedButtons(); }); }); +function invalidateDashboardInspection() { + dashboardOverview = null; + dashboardVoteSiteHealth = null; + dashboardVoteSummary24h = null; + dashboardVoteSummary30d = null; + dashboardLoadedContext = ''; + dashboardInspectionStatus = emptyDashboardInspectionStatus(); + renderMetrics(); +} + async function refreshOverview(target = dataOverview) { + invalidateDashboardInspection(); try { const envelope = await runInspection('overview', {}, target); lastOverview = {...(lastOverview || {}), ...envelope.result}; @@ -3309,10 +4284,122 @@ async function refreshOverview(target = dataOverview) { } catch (error) { text(target, error.message); } } +async function refreshDashboard() { + if (dashboardLoading || inspectionInFlight) { + renderMetrics(); + return; + } + dashboardLoading = true; + inspectionInFlight = true; + refreshDashboardButton.disabled = true; + updateExtendedButtons(); + suppressNodeAutoLoad++; + try { + await loadNodes(); + } finally { + suppressNodeAutoLoad--; + } + await Promise.all([loadEnrollments(), loadOperationHistory()]); + if (!inspectionCapableNode()) { + dashboardLoading = false; + inspectionInFlight = false; + renderMetrics(); + updateExtendedButtons(); + return; + } + const requestedContext = dashboardContext(); + dashboardLoadedContext = ''; + lastOverview = null; + text(dataOverview, 'Refreshing server overview…'); + updateSetupChecklist(); + dashboardOverview = null; + dashboardVoteSiteHealth = null; + dashboardVoteSummary24h = null; + dashboardVoteSummary30d = null; + dashboardInspectionStatus = {overview: 'loading', voteSiteHealth: 'loading', + voteLog24h: 'not-required', voteLog30d: 'not-required'}; + text(attentionFeed, 'Inspecting the selected VotingPlugin server…'); + try { + const overviewEnvelope = await runInspection('overview', {}, null, {manageBusy: false}); + if (requestedContext !== dashboardContext()) throw new Error('Dashboard context changed while inspecting.'); + const overview = normalizeDashboardOverview(overviewEnvelope.result); + dashboardOverview = overview.result; + dashboardInspectionStatus.overview = overview.incomplete ? 'incomplete' : 'available'; + lastOverview = dashboardOverview; + renderJsonResult(dataOverview, dashboardOverview); + updateSetupChecklist(lastOverview); + try { + const healthEnvelope = await runInspection('vote-site-health', {days: '30'}, null, {manageBusy: false}); + if (requestedContext !== dashboardContext()) throw new Error('Dashboard context changed while inspecting.'); + const health = normalizeDashboardVoteSiteHealth(healthEnvelope.result); + dashboardVoteSiteHealth = health.result; + const healthIncomplete = health.incomplete + || dashboardHealthContradictsOverview(dashboardOverview, health.result); + dashboardInspectionStatus.voteSiteHealth = healthIncomplete ? 'incomplete' : 'available'; + } catch (error) { + if (requestedContext !== dashboardContext()) throw error; + dashboardVoteSiteHealth = null; + dashboardInspectionStatus.voteSiteHealth = 'failed'; + } + if (dashboardOverview.voteLogReadable === true) { + dashboardInspectionStatus.voteLog24h = 'loading'; + dashboardInspectionStatus.voteLog30d = 'loading'; + try { + const summaryEnvelope = await runInspection('vote-log-summary', {days: '1'}, null, {manageBusy: false}); + if (requestedContext !== dashboardContext()) throw new Error('Dashboard context changed while inspecting.'); + const summary = normalizeDashboardVoteSummary(summaryEnvelope.result, 1); + dashboardVoteSummary24h = summary.result; + dashboardInspectionStatus.voteLog24h = summary.incomplete ? 'incomplete' : 'available'; + } catch (error) { + if (requestedContext !== dashboardContext()) throw error; + dashboardVoteSummary24h = null; + dashboardInspectionStatus.voteLog24h = 'failed'; + } + try { + const summaryEnvelope = await runInspection('vote-log-summary', {days: '30'}, null, {manageBusy: false}); + if (requestedContext !== dashboardContext()) throw new Error('Dashboard context changed while inspecting.'); + const summary = normalizeDashboardVoteSummary(summaryEnvelope.result); + dashboardVoteSummary30d = summary.result; + dashboardInspectionStatus.voteLog30d = summary.incomplete ? 'incomplete' : 'available'; + } catch (error) { + if (requestedContext !== dashboardContext()) throw error; + dashboardVoteSummary30d = null; + dashboardInspectionStatus.voteLog30d = 'failed'; + } + if (dashboardVoteSummariesContradict(dashboardVoteSummary24h, dashboardVoteSummary30d)) { + dashboardInspectionStatus.voteLog24h = 'incomplete'; + dashboardInspectionStatus.voteLog30d = 'incomplete'; + } + if (dashboardHealthContradictsVoteSummary(dashboardVoteSiteHealth, dashboardVoteSummary30d)) { + dashboardInspectionStatus.voteSiteHealth = 'incomplete'; + dashboardInspectionStatus.voteLog30d = 'incomplete'; + } + } + const complete = Object.values(dashboardInspectionStatus) + .every(status => ['available', 'not-required'].includes(status)); + if (requestedContext === dashboardContext() && complete) dashboardLoadedContext = requestedContext; + } catch (error) { + if (requestedContext === dashboardContext()) { + dashboardLoadedContext = ''; + dashboardInspectionStatus.overview = 'failed'; + text(attentionFeed, error.message || 'Dashboard inspection failed.'); + } + } finally { + dashboardLoading = false; + inspectionInFlight = 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); lastOverview = envelope.result; + invalidateDashboardInspection(); updateSetupChecklist(envelope.result); } catch (error) { text(setupChecklistStatus, error.message); } }); @@ -3324,17 +4411,20 @@ runNetworkDoctor.addEventListener('click', async () => { try { const diagnostics = await runInspection('diagnostics', {}, networkDoctorResults); lastOverview = diagnostics.result; + invalidateDashboardInspection(); const node = nodeIndex.get(selectedServerId); const voteLog = diagnostics.result.voteLoggingEnabled !== true ? {state: 'DISABLED', message: 'Vote logging is disabled; no retained logged-event history is expected.'} : diagnostics.result.voteLogReadable === true ? {state: 'READABLE', message: 'Retained logged-event history is readable. It is not a guaranteed record of every internal vote-delivery hop.'} : {state: 'UNREADABLE', message: 'Vote logging is enabled, but retained logged-event history is not currently readable.'}; + const configuredVoteSites = finiteCount(diagnostics.result.configuredVoteSites); const checks = { controlConnected: Boolean(node?.online), configurationHealthy: diagnostics.result.configurationHealthy, votifierDetected: diagnostics.result.votifierDetected, - voteSitesConfigured: Number(diagnostics.result.configuredVoteSites) > 0, + voteSitesConfigured: configuredVoteSites == null ? null : configuredVoteSites > 0, + voteSitesConfiguredKnown: configuredVoteSites != null, processRewards: diagnostics.result.processRewards, voteLogging: voteLog, topologyReported: isBackend(node) ? proxyReportsFor(node.nodeId).length > 0 || !diagnostics.result.proxyMode : true @@ -3357,6 +4447,7 @@ downloadNetworkDiagnostics.addEventListener('click', () => { }); runDriftCheck.addEventListener('click', async () => { + setConfigView('compare'); const nodeIds = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); const selectedFile = driftFile.value; const requestAuthenticationGeneration = authenticationGeneration; @@ -3505,14 +4596,60 @@ playerLookupForm.addEventListener('submit', async event => { }); loadSiteHealth.addEventListener('click', async () => { - try { renderSiteHealthResult((await runInspection('vote-site-health', {days: '30'}, siteHealthResult)).result); } - catch (error) { text(siteHealthResult, error.message); } + try { + const health = normalizeDashboardVoteSiteHealth( + (await runInspection('vote-site-health', {days: '30'}, siteHealthResult)).result); + dashboardVoteSiteHealth = health.result; + if (dashboardLoadedContext === dashboardContext()) { + const healthContradictsSummary = dashboardHealthContradictsVoteSummary(health.result, dashboardVoteSummary30d); + const healthIncomplete = health.incomplete + || dashboardHealthContradictsOverview(dashboardOverview, health.result) || healthContradictsSummary; + dashboardInspectionStatus.voteSiteHealth = healthIncomplete ? 'incomplete' : 'available'; + if (healthContradictsSummary) dashboardInspectionStatus.voteLog30d = 'incomplete'; + if (dashboardInspectionStatus.voteSiteHealth === 'incomplete') dashboardLoadedContext = ''; + } + renderSiteHealthResult(health.result); + renderMetrics(); + } + catch (error) { + if (dashboardLoadedContext === dashboardContext()) { + dashboardInspectionStatus.voteSiteHealth = 'failed'; + dashboardLoadedContext = ''; + } + text(siteHealthResult, error.message); + renderMetrics(); + } }); loadVoteLogSummary.addEventListener('click', async () => { - try { renderJsonResult(voteLogSummaryResult, - (await runInspection('vote-log-summary', {days: '30'}, voteLogSummaryResult)).result); } - catch (error) { text(voteLogSummaryResult, error.message); } + try { + const summary = normalizeDashboardVoteSummary( + (await runInspection('vote-log-summary', {days: '30'}, voteLogSummaryResult)).result); + dashboardVoteSummary30d = summary.result; + if (dashboardLoadedContext === dashboardContext()) { + const summariesContradict = dashboardVoteSummariesContradict( + dashboardVoteSummary24h, dashboardVoteSummary30d); + const healthContradictsSummary = dashboardHealthContradictsVoteSummary( + dashboardVoteSiteHealth, dashboardVoteSummary30d); + dashboardInspectionStatus.voteLog30d = summary.incomplete || summariesContradict || healthContradictsSummary + ? 'incomplete' : 'available'; + if (summariesContradict) { + dashboardInspectionStatus.voteLog24h = 'incomplete'; + } + if (healthContradictsSummary) dashboardInspectionStatus.voteSiteHealth = 'incomplete'; + if (summary.incomplete || summariesContradict || healthContradictsSummary) dashboardLoadedContext = ''; + } + renderJsonResult(voteLogSummaryResult, summary.result); + renderMetrics(); + } + catch (error) { + if (dashboardLoadedContext === dashboardContext()) { + dashboardInspectionStatus.voteLog30d = 'failed'; + dashboardLoadedContext = ''; + } + text(voteLogSummaryResult, error.message); + renderMetrics(); + } }); voteLogFilterType.addEventListener('change', () => { @@ -3570,6 +4707,7 @@ rewardSimulationForm.addEventListener('submit', async event => { }); previewReward.addEventListener('click', async () => { dedicatedSetupApprovals.delete('reward-builder'); + const previewGeneration = inputGeneration; try { const proposal = JSON.stringify(rewardProposal()); if (new TextEncoder().encode(proposal).length > 64 * 1024) throw new Error('Reward proposal exceeds the 64 KiB limit.'); @@ -3579,7 +4717,8 @@ previewReward.addEventListener('click', async () => { nodeIds, configuration: {domain: 'quick-setup', preset: 'reward-builder', options: {proposal}} }, rewardSimulationResult); - if (signature !== JSON.stringify({nodeIds: backendQuickTargets(), proposal: JSON.stringify(rewardProposal())})) { + if (previewGeneration !== inputGeneration + || signature !== JSON.stringify({nodeIds: backendQuickTargets(), proposal: JSON.stringify(rewardProposal())})) { text(rewardSimulationResult, 'The target scope or reward changed while previewing. Preview again.'); } else if (operation.state === 'SUCCEEDED' && operation.approvalToken) { dedicatedSetupApprovals.set('reward-builder', {operationId: operation.operationId, @@ -3592,17 +4731,24 @@ applyReward.addEventListener('click', async () => { const approval = dedicatedSetupApprovals.get('reward-builder'); if (!approval || !window.confirm('Apply this exact reward preview to every selected Bukkit node? It replaces the selected Rewards subtree; sibling sites, scopes, and settings remain unchanged.')) return; dedicatedSetupApprovals.delete('reward-builder'); + const submittedReward = JSON.stringify({proposal: rewardProposal(), nodeIds: approval.nodeIds}); try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, rewardSimulationResult); - if (operation.state === 'SUCCEEDED') fileReadCache.clear(); + const currentReward = JSON.stringify({proposal: rewardProposal(), nodeIds: backendQuickTargets()}); + if (operation.state === 'SUCCEEDED' && submittedReward === currentReward) { + fileReadCache.clear(); + } else if (operation.state === 'SUCCEEDED') { + text(rewardSimulationResult, `${operationSummary(operation)}\nThe apply completed, but newer reward edits remain. Preview again before applying them.`); + } } catch (error) { text(rewardSimulationResult, error.message); } updateExtendedButtons(); }); [rewardScope, rewardSite, rewardChance, rewardMoney, rewardCommands, rewardMessages, rewardBroadcasts, rewardPermissions, rewardItems, rewardOnlineOnly].forEach(field => field.addEventListener('input', () => { dedicatedSetupApprovals.delete('reward-builder'); + inputGeneration++; copyRewardToSetup.disabled = boundedLines(rewardCommands.value).length === 0; updateExtendedButtons(); })); @@ -3617,7 +4763,7 @@ copyRewardToSetup.addEventListener('click', () => { quickMessage.value = boundedLines(rewardMessages.value)[0] || ''; updateQuickFields(); clearApprovals(); - document.querySelector('#quick-setup-card').scrollIntoView({behavior: 'smooth', block: 'start'}); + scrollToAnchor(document.querySelector('#quick-setup-card')); }); settingsFilter.addEventListener('input', renderSettingsCatalog); @@ -3741,10 +4887,13 @@ quickPreset.addEventListener('input', () => { } }); serverPicker.addEventListener('change', () => selectPrimaryServer(serverPicker.value)); -tabButtons.forEach(button => button.addEventListener('click', () => setActiveTab(button.dataset.tab, true))); +tabButtons.forEach(button => button.addEventListener('click', () => { + if (button.dataset.configShortcut) setConfigView(button.dataset.configShortcut); + 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); @@ -3755,12 +4904,131 @@ 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; + })); +} + +const GLOBAL_PAGE_SHORTCUTS = new Map([ + ['add vote site', {tab: 'quick-setup', scrollTarget: 'quick-setup-card', preset: 'vote-site'}], + ['vote sites', {tab: 'data', scrollTarget: 'site-health-card'}], + ['rewards', {tab: 'quick-setup', scrollTarget: 'reward-builder-card'}], + ['setup', {tab: 'quick-setup', scrollTarget: 'quick-setup-card'}], + ['settings', {tab: 'quick-setup', scrollTarget: 'settings-catalog-card'}], + ['vote logging', {tab: 'quick-setup', scrollTarget: 'quick-setup-card', preset: 'vote-logging'}], + ['servers', {tab: 'servers'}], + ['proxy & routing', {tab: 'network'}], + ['sync vote sites', {tab: 'quick-setup', scrollTarget: 'quick-setup-card', preset: 'sync-vote-sites'}], + ['votes & data', {tab: 'data'}], + ['activity', {tab: 'activity'}], + ['network doctor', {tab: 'network', scrollTarget: 'network-doctor-card'}], + ['configuration compare', {tab: 'configurations', configView: 'compare', scrollTarget: 'drift-results'}], + ['access', {tab: 'access'}] +]); + +function openGlobalShortcut(destination) { + if (destination.configView) setConfigView(destination.configView); + openWorkspace(destination.tab, destination.scrollTarget || '', destination.preset || ''); +} + +globalSearch.addEventListener('submit', event => { + event.preventDefault(); + const query = globalSearchInput.value.trim(); + const normalized = query.toLowerCase(); + if (!normalized) return; + const exactShortcut = GLOBAL_PAGE_SHORTCUTS.get(normalized); + if (exactShortcut) { + openGlobalShortcut(exactShortcut); + globalSearchInput.value = ''; + 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) { + const nodePosition = allNodeItems.indexOf(node); + selectNodePage(Math.floor(nodePosition / PAGE_SIZE) * PAGE_SIZE); + openWorkspace('servers'); + selectPrimaryServer(node.nodeId); + // The same-server fast path intentionally avoids resetting drafts and does + // not render. Always paint the newly selected page slice after navigation. + renderNodeViews(); + globalSearchInput.value = ''; + return; + } + const setting = SETTINGS_SCHEMA.find(item => Object.values(item).join(' ').toLowerCase().includes(normalized)); + 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')) { + openGlobalShortcut(GLOBAL_PAGE_SHORTCUTS.get('configuration compare')); + } + 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(); @@ -3776,6 +5044,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..bcf9809f 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -8,11 +8,29 @@
+ VotingPluginControl
+ Connecting… +
@@ -67,31 +86,45 @@

Sign in to your network

- diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournalTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournalTest.java index b5777d87..eb3279bc 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournalTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationJournalTest.java @@ -77,6 +77,20 @@ class ConfigurationOperationJournalTest { assertEquals(1, state.operations().size()); assertTrue(state.voteLoggingRestartSessions().isEmpty()); + assertEquals(0L, state.configurationGeneration()); + } + + @Test void preservesConfigurationGenerationAcrossRestart() throws Exception { + ConfigurationOperationJournal journal = new ConfigurationOperationJournal(directory, clock); + journal.save(List.of(), Map.of(), 17L); + + assertEquals(17L, journal.loadState().configurationGeneration()); + } + + @Test void rejectsNegativeConfigurationGeneration() throws Exception { + ConfigurationOperationJournal journal = new ConfigurationOperationJournal(directory, clock); + + assertThrows(IOException.class, () -> journal.save(List.of(), Map.of(), -1L)); } @Test void rejectsMalformedOversizedAndUnsafeJournalFiles() throws Exception { diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 15bd06d0..3838dac7 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -956,6 +956,24 @@ class ConfigurationOperationsTest { assertEquals("DEPENDENCY_CHANGED", result.results().get("proxy-a").code()); } + @Test void eachSuccessfulApplyTargetAdvancesConfigurationGeneration() throws Exception { + ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofMinutes(5)); + ManagedConfiguration method = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "REDIS")); + + ConfigurationTask backendApply = fixture.operations().claim("lobby", fixture.backendSession()); + fixture.operations().complete(fixture.applyId(), "lobby", new ConfigurationTaskResult( + fixture.backendSession(), true, "OK", "applied", "c".repeat(64), method, List.of(), false, false, + backendApply.attemptId())); + assertEquals(1L, fixture.operations().listView().configurationGeneration()); + + ConfigurationTask proxyApply = fixture.operations().claim("proxy-a", fixture.proxySession()); + fixture.operations().complete(fixture.applyId(), "proxy-a", new ConfigurationTaskResult( + fixture.proxySession(), true, "OK", "applied", "d".repeat(64), method, List.of(), false, false, + proxyApply.attemptId())); + assertEquals(2L, fixture.operations().listView().configurationGeneration()); + } + @Test void proxyMethodApplyRejectsAChangedTargetRole() throws Exception { ProxyMethodFixture fixture = proxyMethodApplyFixture(Duration.ofMinutes(5)); ConfigurationTask backendApply = fixture.operations().claim("lobby", fixture.backendSession()); @@ -1412,9 +1430,11 @@ class ConfigurationOperationsTest { ConfigurationTask secondApply = operations.claim("backend-b", second); operations.complete(apply.operationId(), "backend-a", new ConfigurationTaskResult(first, true, "OK", "applied", "c".repeat(64), proposal, List.of(), true, false, firstApply.attemptId())); + assertEquals(1L, operations.listView().configurationGeneration()); apply = operations.complete(apply.operationId(), "backend-b", new ConfigurationTaskResult(second, false, "WRITE_FAILED", "failed", null, (ManagedConfiguration) null, List.of(), false, true, secondApply.attemptId())); + assertEquals(1L, operations.listView().configurationGeneration()); ConfigurationOperations.OperationView retry = operations.retry(apply.operationId()); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 34ff9bda..fc9141c0 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -74,6 +74,25 @@ class ControlHttpServerTest { assertEquals(200, script.statusCode()); assertTrue(script.body().contains("offset=${offset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("async function loadAllNodes()")); + assertTrue(script.body().contains("let nodeLoadInFlight = null;")); + assertTrue(script.body().contains("async function loadNodesOnce()")); + assertTrue(script.body().contains("nodeLoadQueued = true;")); + assertTrue(script.body().contains("let nodeLoadQueuedPromise = null;")); + assertTrue(script.body().contains("return nodeLoadQueuedPromise;"), + "A queued registry refresh must remain awaitable by its caller."); + assertTrue(script.body().contains("loadNodes().then(resolveQueued, rejectQueued);"), + "A queued registry refresh must settle only after the follow-up pass completes."); + assertTrue(script.body().contains("let operationHistoryLoadInFlight = null;")); + assertTrue(script.body().contains("let operationHistoryLoadQueued = false;")); + assertTrue(script.body().contains("async function loadOperationHistoryOnce()")); + assertTrue(script.body().contains("while (operationHistoryLoadQueued && authenticated)")); + assertTrue(script.body().contains(": tab === 'servers' ? nodeLoadInFlight != null")); + assertTrue(script.body().contains("rootStyleRule?.['style'].setProperty('--topbar-height', `${height}px`)")); + assertTrue(script.body().contains("function scrollToAnchor(target)")); + assertTrue(script.body().contains("syncTopbarOffset();\n target.scrollIntoView({behavior: 'smooth', block: 'start'});")); + assertTrue(script.body().contains("window.requestAnimationFrame(() => scrollToAnchor(document.getElementById(scrollTarget)))")); + assertTrue(script.body().contains("Math.max(topbarBounds.bottom, searchBounds.bottom)")); + assertTrue(script.body().contains("globalSearch.hidden = false;\n syncTopbarOffset();")); assertTrue(script.body().contains("MAX_REGISTRY_SCAN_ATTEMPTS")); assertTrue(script.body().contains("&revision=${revision}")); assertTrue(script.body().contains("enrollmentIds.has(backend.backendId)")); @@ -106,6 +125,35 @@ class ControlHttpServerTest { assertTrue(script.body().contains("loadEnrollments")); assertTrue(script.body().contains("enrollmentMutationInFlight")); assertTrue(script.body().contains("enrollmentRefreshRequested")); + assertTrue(script.body().contains("let enrollmentRefreshPromise = null;"), + "Enrollment callers must be able to await a refresh queued behind an in-flight request."); + assertTrue(script.body().contains("if (!enrollmentRefreshPromise) {"), + "Concurrent enrollment refresh callers must share one bounded waiter promise."); + assertTrue(script.body().contains("return enrollmentRefreshPromise;"), + "A queued enrollment refresh must not let dashboard inspection proceed on stale enrollment state."); + assertFalse(script.body().contains("enrollmentRefreshWaiters"), + "Enrollment refresh waiters must not grow without a bound."); + assertTrue(script.body().contains("const reportedBackends = new Map();"), + "Node-level topology warnings must be aggregated before rendering attention items."); + assertTrue(script.body().contains("is unavailable to ${proxy.displayName}"), + "Availability warnings must remain distinct for every reporting proxy."); + assertTrue(script.body().contains( + "if (nodeId && nodeId === selectedServerId) {\n serverPicker.value = selectedServerId;\n return;\n }"), + "Selecting the current server again must not reset unsaved YAML or routing drafts."); + int primarySelector = script.body().indexOf("function selectPrimaryServer(nodeId)"); + int sameServerGuard = script.body().indexOf( + "if (nodeId && nodeId === selectedServerId)", primarySelector); + assertTrue(primarySelector >= 0 && sameServerGuard > primarySelector + && sameServerGuard < script.body().indexOf( + "confirmDiscardUnsavedConfiguration('switching servers')", sameServerGuard) + && sameServerGuard < script.body().indexOf("resetServerContextValues(", sameServerGuard), + "The same-server guard must run before any draft-discard confirmation or context reset."); + assertTrue(script.body().contains( + "const registeredBukkitBackend = registered?.platform === 'BUKKIT';"), + "A proxy or non-Bukkit registry node must not satisfy a proxy backend report."); + assertTrue(script.body().contains( + "if (enrollmentsLoaded && registeredBukkitBackend && !enrollmentIds.has(backend.backendId))"), + "Enrollment health must only be evaluated for a registered Bukkit backend."); assertTrue(script.body().contains("await loadEnrollments()")); assertTrue(script.body().contains("enrollmentSubmit.disabled = true")); assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); @@ -185,8 +233,53 @@ class ControlHttpServerTest { assertTrue(script.body().contains("previewConfiguration.disabled = !routingDraftReady;")); assertTrue(script.body().contains("applyConfiguration.disabled = !routingDraftReady || !approvedPreview;")); assertTrue(script.body().contains("Your unsaved proxy-routing draft is retained")); - assertTrue(script.body().contains("fileReadCache.clear();\n lastFileReadOperation = null;\n }\n if (operationContextCurrent(context)"), - "Every successful apply must invalidate cached reads even after the view context changes."); + assertTrue(script.body().contains("function invalidateConfigurationReads() {\n fileReadCache.clear();\n" + + " lastFileReadOperation = null;\n clearApprovals();\n loadedQuickSetup = null;\n" + + " if (!configurationDirty) {\n" + + " configurationContent.value = '';\n configurationContentPresent = false;\n" + + " text(fileOperationStatus, 'Configuration changed; read the current file before previewing changes.');\n" + + " }\n lastOverview = null;\n lastDiagnostics = null;\n" + + " dashboardConfigurationGeneration++;\n invalidateDashboardInspection();"), + "Every successful apply must invalidate file and dashboard reads even after the view context changes."); + assertTrue(script.body().contains("function clearApprovals() {\n approvedPreview = null;\n" + + " approvedFilePreview = null;\n approvedQuickPreview = null;\n" + + " dedicatedSetupApprovals.clear();\n inputGeneration++;\n updateConfigurationButtons();"), + "External applies must invalidate every approval and fence delayed configuration responses."); + assertTrue(script.body().contains("const serverConfigurationGeneration = finiteCount(body.configurationGeneration);")); + assertTrue(script.body().contains("serverConfigurationGeneration > observedServerConfigurationGeneration")); + assertTrue(script.body().contains("Math.max(observedServerConfigurationGeneration, serverConfigurationGeneration)"), + "A delayed older response must not move the observed server generation backwards."); + assertTrue(script.body().contains("if (observedSuccessfulApply) invalidateConfigurationReads();"), + "Activity refreshes must invalidate cached health after observing an external successful apply."); + assertTrue(script.body().contains("if (applied) {\n invalidateConfigurationReads();"), + "Locally completed applies must use the same cache invalidation path."); + assertTrue(script.body().contains("const submittedFile = JSON.stringify({content: configurationContent.value, fileName: approval.fileName,")); + assertTrue(script.body().contains("const currentFile = JSON.stringify({content: configurationContent.value, fileName: configurationFile.value,")); + assertTrue(script.body().contains("submittedFile === currentFile"), + "File apply completion must compare content, target scope, file, and node sessions."); + assertTrue(script.body().contains("The apply completed, but newer unsaved file edits remain. Preview again before applying them."), + "A file apply must not label edits made during polling as already saved."); + assertTrue(script.body().contains("const previewGeneration = inputGeneration;\n try {\n const nodeIds = backendQuickTargets();")); + assertTrue(script.body().contains("if (previewGeneration !== inputGeneration\n" + + " || signature !== JSON.stringify"), + "Dedicated previews completed after another apply must not restore stale approvals."); + assertTrue(script.body().contains("dedicatedSetupApprovals.delete(field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging');\n" + + " inputGeneration++;\n updateExtendedButtons();"), + "Dedicated setup edits must fence delayed reads before they can overwrite newer input."); + assertTrue(script.body().contains("const submittedOptions = JSON.stringify(dedicatedSetupOptions(preset));")); + assertTrue(script.body().contains("const inputsCurrent = submittedOptions === JSON.stringify(dedicatedSetupOptions(preset))")); + assertTrue(script.body().contains("The apply completed, but newer setup edits remain. Preview again before applying them."), + "Dedicated apply results must not label newer form values as saved."); + assertTrue(script.body().contains("The apply completed, but newer guided setup edits remain. Preview again before applying them.")); + assertTrue(script.body().contains("const submittedQuickSetup = JSON.stringify")); + assertTrue(script.body().contains("The apply completed, but newer reward edits remain. Preview again before applying them.")); + assertTrue(script.body().contains("const submittedReward = JSON.stringify")); + assertTrue(script.body().contains("dedicatedSetupApprovals.delete('reward-builder');\n inputGeneration++;"), + "Reward edits must fence delayed preview and apply results."); + assertTrue(script.body().contains("previewReward.addEventListener('click', async () => {\n" + + " dedicatedSetupApprovals.delete('reward-builder');\n" + + " const previewGeneration = inputGeneration;"), + "Reward previews must also be fenced when another apply invalidates their base revision."); assertTrue(script.body().contains("Drift results were discarded; run the comparison again."), "A drift read completed for stale context must show an explicit discarded-result status."); assertTrue(script.body().contains("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); @@ -243,13 +336,358 @@ class ControlHttpServerTest { assertTrue(script.body().contains(".slice(0, MAX_PLAYER_LAST_VOTES)")); assertTrue(script.body().contains("if (!automatic) text(fileOperationStatus, error.message);")); assertTrue(script.body().contains("const cell = document.createElement('td');")); - assertTrue(script.body().contains("const applyGeneration = inputGeneration + 1;")); - assertTrue(script.body().contains("operation.state === 'SUCCEEDED' && applyGeneration === inputGeneration")); + assertTrue(script.body().contains("const submittedProposal = JSON.stringify({proposal: proposal(), nodeIds: approval.nodeIds});")); + assertTrue(script.body().contains("const currentProposal = JSON.stringify({proposal: proposal(), nodeIds: targets('config.proxy-routing.v1')});")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); HttpResponse stylesheet = get("/app.css", null); assertEquals(200, stylesheet.statusCode()); - assertTrue(stylesheet.body().contains(".tabs")); + assertTrue(stylesheet.body().contains(".sidebar")); + assertTrue(stylesheet.body().contains("--topbar-height: 76px")); + assertTrue(stylesheet.body().contains("[id] { scroll-margin-top: calc(var(--topbar-height) + 20px); }"), + "Anchored shortcuts must clear the dynamically measured sticky header on desktop and mobile."); + assertTrue(stylesheet.body().contains("top: calc(var(--topbar-height) + 24px)")); + assertTrue(stylesheet.body().contains("max-height: calc(100vh - var(--topbar-height) - 44px)")); + assertTrue(stylesheet.body().contains("@media (max-width: 1360px) and (min-width: 921px)")); + assertTrue(stylesheet.body().contains("@media (max-width: 480px)"), + "The compact header must hide the brand before the 430px overflow range."); + assertTrue(stylesheet.body().contains(".topbar-actions { flex: 1 1 520px; min-width: 0; flex-wrap: wrap; }")); + assertFalse(stylesheet.body().contains("attr(data-topbar-height")); + assertTrue(stylesheet.body().contains("body::before { position: fixed; z-index: 25; top: var(--topbar-height)")); + assertTrue(stylesheet.body().contains(".sidebar { position: fixed; z-index: 30; top: var(--topbar-height)")); + assertTrue(web.body().contains("id=\"primary-navigation\"")); + assertTrue(web.body().contains("id=\"attention-feed\"")); + assertTrue(web.body().contains("id=\"global-search-input\"")); + assertTrue(web.body().contains("Logged Votes · 30d")); + assertTrue(script.body().contains("async function refreshDashboard()")); + assertTrue(script.body().contains( + "dashboardLoading = true;\n inspectionInFlight = true;\n refreshDashboardButton.disabled = true;\n updateExtendedButtons();\n suppressNodeAutoLoad++"), + "Dashboard refresh must reserve the shared inspection lane before any awaited registry or metadata prefetch."); + assertTrue(script.body().contains( + "refreshDashboardButton.disabled = !authenticated || inspectionInFlight || dashboardLoading;"), + "Dashboard refresh must remain available to rediscover a reconnected inspection-capable node."); + assertTrue(script.body().contains( + "const unavailable = tab === 'overview' ? dashboardLoading || inspectionInFlight"), + "The overview header refresh must not be gated by stale cached node capabilities."); + assertTrue(script.body().contains("function dashboardIssues()")); + assertTrue(script.body().contains("operationHistoryStatus = 'failed';")); + assertTrue(script.body().contains("const historyGeneration = authenticationGeneration;")); + assertEquals(2, script.body().split(java.util.regex.Pattern.quote( + "if (!authenticated || historyGeneration !== authenticationGeneration) return;"), -1).length - 1, + "Delayed operation-history success and failure responses must not mutate a replaced session."); + assertTrue(script.body().contains("operationHistoryItems = [];\n voteLoggingRestartPending = new Map();"), + "A failed history refresh must not leave stale operations or restart warnings on the dashboard."); + assertTrue(script.body().contains( + "text(operationHistory, error.message || 'Operation history could not be loaded.');\n updateSetupChecklist();"), + "Clearing restart state after a failed history refresh must update the setup checklist."); + assertTrue(script.body().contains("if (operationHistoryStatus === 'failed') issues.push(issue('warning'"), + "Unavailable operation history must downgrade dashboard health."); + assertTrue(script.body().contains("let enrollmentStatus = 'not-loaded';")); + assertTrue(script.body().contains("enrollmentStatus = 'failed';")); + assertTrue(script.body().contains("if (enrollmentStatus === 'failed') issues.push(issue('warning'"), + "Unavailable enrollment state must downgrade dashboard health."); + assertTrue(script.body().contains("openWorkspace('activity');\n return loadOperationHistory();"), + "Retry activity must reload operation history after navigating to its page."); + assertTrue(script.body().contains("openWorkspace('access');\n return loadEnrollments();"), + "Retry access must reload enrollments after navigating to its page."); + assertTrue(script.body().contains("dashboardConfigurationGeneration++")); + assertTrue(script.body().contains("|${dashboardConfigurationGeneration}`")); + assertTrue(script.body().contains("Configuration changed; refreshing server overview")); + assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);")); + assertTrue(script.body().contains("await loadNodes();\n } finally {\n suppressNodeAutoLoad--;\n }\n await Promise.all([loadEnrollments(), loadOperationHistory()]);\n if (!inspectionCapableNode())"), + "Dashboard refresh must reload node connectivity before reloading metadata and checking inspection capability."); + assertTrue(script.body().contains("if (suppressNodeAutoLoad === 0) void autoLoadTab(tabFromHash());"), + "An internal dashboard registry refresh must not recursively queue another dashboard load."); + assertTrue(script.body().contains("await Promise.all([loadEnrollments(), loadOperationHistory()]);\n if (!inspectionCapableNode()"), + "An explicit dashboard refresh must reload enrollments and operation history before inspections."); + assertTrue(script.body().contains("Object.keys(operation.nodeStates || {}).length || results.length"), + "Running-operation progress must count all targets, not only completed results."); + assertTrue(script.body().contains("runInspection('overview', {}, null, {manageBusy: false})")); + assertTrue(script.body().contains("inspectionInFlight = false;\n renderMetrics();"), + "A dashboard refresh with no inspection-capable node must release the reserved lane."); + assertEquals(4, script.body().split(java.util.regex.Pattern.quote( + "if (requestedContext !== dashboardContext()) throw new Error('Dashboard context changed while inspecting.');"), + -1).length - 1); + assertFalse(web.body().contains("data-search-term=")); + assertTrue(script.body().contains("result.enabledVoteSites > result.configuredVoteSites")); + assertTrue(script.body().contains("renderJsonResult(dataOverview, dashboardOverview);")); + assertTrue(script.body().contains("const summary = {items: [], total: 0, actionable: 0")); + assertTrue(script.body().contains("if (summary.items.length < 30) summary.items.push(item);")); + assertTrue(script.body().contains("const actionable = issueSummary.actionable;")); + assertTrue(script.body().contains("!selected.online || !current || hasWarning")); + assertFalse(script.body().contains("return issues.slice(0, 30);")); + assertTrue(script.body().contains( + "configurations: ['Compare configuration', () => {\n setConfigView('compare');\n runDriftCheck.click();")); + assertTrue(script.body().contains( + "dashboardLoadedContext = '';\n dashboardInspectionStatus.overview = 'failed';")); + assertTrue(script.body().contains("if (requestedContext === dashboardContext() && complete)")); + assertTrue(script.body().contains("disconnected from Control")); + assertTrue(script.body().contains("disconnectedNodes.forEach(node => {"), + "Every disconnected registered node must contribute to the actionable issue total."); + assertFalse(script.body().contains("disconnectedNodes.slice(0, 10)"), + "Disconnected-node issue totals must not stop at the first ten nodes."); + assertTrue(script.body().contains( + "(Array.isArray(proxy.backends) ? proxy.backends : []).forEach(backend => {"), + "Dashboard topology health must inspect every backend returned by the bounded nodes API."); + assertFalse(script.body().contains( + "(Array.isArray(proxy.backends) ? proxy.backends : []).slice(0, 100).forEach(backend => {"), + "Dashboard topology health must not silently omit backend summaries after the first 100 rows."); + assertTrue(script.body().contains("document.createElement('progress')")); + assertTrue(script.body().contains("const count = hasCount ? finiteCount(entry.count) : hasVotes ? finiteCount(entry.votes) : null;")); + assertTrue(script.body().contains("Math.max(...services.map(service => service.count), 1)")); + assertTrue(script.body().contains("track.value = service.count;")); + assertFalse(script.body().contains("service.votes")); + assertTrue(script.body().contains("dashboardInspectionStatus.voteSiteHealth = 'failed';")); + assertTrue(script.body().contains("dashboardInspectionStatus.voteSiteHealth = 'failed';\n dashboardLoadedContext = '';")); + assertTrue(script.body().contains("dashboardInspectionStatus.voteLog24h = 'failed';")); + assertTrue(script.body().contains("dashboardInspectionStatus.voteLog30d = 'failed';")); + assertTrue(script.body().contains("dashboardInspectionStatus.voteLog30d = 'failed';\n dashboardLoadedContext = '';")); + assertTrue(script.body().contains("Some dashboard checks could not be verified")); + assertTrue(script.body().contains("!current || hasWarning || hasIncompleteInspection ? 'Warning' : 'Healthy'")); + assertTrue(script.body().contains("function normalizeDashboardCollection(value, maximum, normalize)")); + assertTrue(script.body().contains("if (!Array.isArray(value)) return {items: [], incomplete: true};")); + assertTrue(script.body().contains("normalizeDashboardVoteSiteHealth")); + assertTrue(script.body().contains("dashboardHealthContradictsOverview(dashboardOverview, health.result)")); + assertTrue(script.body().contains("health.sites.length === configured")); + assertTrue(script.body().contains("health.sites.filter(site => site.enabled === true).length === enabled")); + assertTrue(script.body().contains("['truncated', 'detectedUnconfiguredServicesTruncated'].forEach")); + assertTrue(script.body().contains("typeof source[field] !== 'boolean' || source[field] === true")); + assertTrue(script.body().contains( + "else if (dashboardOverview.enabledVoteSites === 0) issues.push(issue('warning', 'All Vote Sites are disabled'"), + "A configured backend with every Vote Site disabled must not appear healthy."); + assertTrue(script.body().contains("const allConfiguredSitesDisabled = siteCountsKnown && configured > 0 && enabled === 0;")); + assertTrue(script.body().contains("+ (allConfiguredSitesDisabled ? 1 : 0)"), + "The Vote Sites summary must count the all-disabled warning as needing attention."); + assertTrue(script.body().contains("sites.filter(site => site.status === 'SERVICE_SITE_MISSING')"), + "Individually disabled Vote Sites must remain quiet when another site is enabled."); + assertTrue(script.body().contains("const voteSiteKeys = new Set();")); + assertTrue(script.body().contains("const canonicalKey = key.value.toLowerCase();")); + assertTrue(script.body().contains("const key = boundedDashboardString(entry.key, 64, true);"), + "Vote-site health must reject keys beyond the 64-character wire limit."); + assertTrue(script.body().contains("|| !key.value || voteSiteKeys.has(canonicalKey)"), + "Vote-site health must reject empty and duplicate site keys as incomplete data."); + assertFalse(script.body().contains("(!key.value && !displayName.value)"), + "A display name must not substitute for a missing vote-site key."); + assertTrue(script.body().contains("voteSiteKeys.add(canonicalKey);")); + assertTrue(script.body().contains("const serviceSite = boundedDashboardString(entry.serviceSite, 64, true);"), + "Vote-site health must enforce the node's 64-character ServiceSite wire bound."); + assertTrue(script.body().contains("const detectedServiceKeys = new Set();")); + assertTrue(script.body().contains("const configuredServiceKeys = new Set(sites.items.map(site => site.serviceSite.toLowerCase()));")); + assertTrue(script.body().contains("configuredServiceKeys.has(identity)"), + "Detected services must not duplicate a configured ServiceSite identity."); + assertTrue(script.body().contains( + "if (service.incomplete || configuredServiceKeys.has(identity) || unmatchedServiceKeys.has(identity)) return null;"), + "Unmatched services must not duplicate a configured ServiceSite identity."); + assertTrue(script.body().contains("const unmatchedServiceKeys = new Set();")); + assertTrue(script.body().contains("const service = boundedDashboardString(entry.serviceSite, 64);"), + "Unmatched VoteLog identities must use the same 64-character wire limit as other service values."); + assertTrue(script.body().contains("unmatchedServiceKeys.has(identity)"), + "Unmatched service identities must be rejected case-insensitively when duplicated."); + assertTrue(script.body().contains("unmatchedServiceKeys.add(identity)")); + assertTrue(script.body().contains("detectedServiceKeys.has(identity)"), + "Detected service identities must be rejected case-insensitively when duplicated."); + assertTrue(script.body().contains( + "if (source.voteLogReadable !== true && unmatched.items.length > 0) incomplete = true;"), + "Unmatched services must make unreadable VoteLog health data incomplete."); + assertTrue(script.body().contains("source.voteLogReadable === true ? unmatched.items : []"), + "Unreadable VoteLog data must not render non-authoritative unmatched services."); + assertTrue(script.body().contains("normalizeDashboardVoteSummary")); + assertTrue(script.body().contains("countRowsExceedTotal(services.items, total)")); + assertTrue(script.body().contains("countRowsExceedTotal(servers.items, total)")); + assertTrue(script.body().contains("function countRowsSumMatchesTotal(items, total)")); + assertTrue(script.body().contains( + "return items.length >= 20 || items.reduce((sum, entry) => sum + entry.count, 0) === total;"), + "Untruncated VoteLog category lists must account for every vote while retaining truncated-list behavior."); + assertTrue(script.body().contains("!countRowsSumMatchesTotal(services.items, total)")); + assertTrue(script.body().contains("!countRowsSumMatchesTotal(servers.items, total)")); + assertTrue(script.body().contains("total > 0 && (services.items.length === 0 || servers.items.length === 0"), + "A nonempty VoteLog total must include at least one top service and server."); + assertTrue(script.body().contains("!countRowsArePositive(services.items) || !countRowsArePositive(servers.items)"), + "VoteLog category rows must not include zero-count rows, including when the total is zero."); + assertFalse(script.body().contains("total > 0 && (services.items.length === 0 || servers.items.length === 0\n || !countRowsArePositive(services.items)"), + "Zero-total VoteLog summaries must not bypass positive category-count validation."); + assertTrue(script.body().contains("function countRowsArePositive(items)")); + assertTrue(script.body().contains("return items.every(entry => entry.count > 0);")); + assertTrue(script.body().contains("function countRowsAreNonIncreasing(items)"), + "VoteLog category rows must retain their descending-count order."); + assertTrue(script.body().contains("if (items[index].count > items[index - 1].count) return false;")); + assertTrue(script.body().contains("!countRowsAreNonIncreasing(services.items)")); + assertTrue(script.body().contains("!countRowsAreNonIncreasing(servers.items)")); + assertTrue(script.body().contains("function normalizeDashboardCountRows(value, maximum, label)")); + assertTrue(script.body().contains("boundedDashboardString(entry[label], 64, false)"), + "VoteLog service and server identities must enforce the 64-character wire limit."); + assertTrue(script.body().contains("const identities = new Set();")); + assertTrue(script.body().contains("const identity = name.value.toLowerCase();")); + assertTrue(script.body().contains("if (identities.has(identity)) return null;")); + assertTrue(script.body().contains("function invalidateDashboardInspection()")); + assertTrue(script.body().contains("lastOverview = diagnostics.result;\n invalidateDashboardInspection();")); + assertTrue(script.body().contains("lastOverview = envelope.result;\n invalidateDashboardInspection();"), + "Setup diagnostics must invalidate any cached dashboard evidence."); + assertTrue(script.body().contains("function invalidVoteLoggingState(value)")); + assertTrue(script.body().contains("Object.hasOwn(value, 'voteLogAvailable') ? value.voteLogAvailable : value.voteLoggingAvailable")); + assertTrue(script.body().contains("const proxyMethods = new Set(['PLUGINMESSAGING', 'REDIS', 'MQTT', 'MYSQL', 'SOCKETS']);")); + assertTrue(script.body().contains("result.proxyMode === true && !proxyMethods.has(result.proxyMethod.toUpperCase())")); + assertTrue(script.body().contains("const requiredStrings = new Set(['pluginVersion', 'platform', 'serverSoftware', 'serverVersion', 'dataStorage']);")); + assertTrue(script.body().contains("field === 'proxyMethod'")); + assertTrue(script.body().contains("const platforms = new Set(['BUKKIT']);")); + assertTrue(script.body().contains("const dataStorages = new Set(['SQLITE', 'MYSQL']);")); + assertTrue(script.body().contains("immediate + cached !== total")); + assertTrue(script.body().contains("total > 0 && uniqueVoters <= 0"), + "A nonempty VoteLog summary must report at least one unique voter."); + assertTrue(script.body().contains("function dashboardVoteSummariesContradict(shortWindow, longWindow)")); + assertTrue(script.body().contains( + "const scalarContradiction = ['total', 'immediate', 'cached', 'uniqueVoters'].some(field =>"), + "All shared VoteLog counters must be monotonic across nested windows."); + assertTrue(script.body().contains("function dashboardCountRowIdentity(entry, label)"), + "VoteLog top-row comparisons must use the same normalized identity rules as row validation."); + assertTrue(script.body().contains("function dashboardCountRowsContradict(shortRows, longRows, label)")); + assertTrue(script.body().contains("longCounts.has(identity)"), + "Shared nested-window identities must be compared."); + assertTrue(script.body().contains("const widerWindowIsComplete = longRows.length < 20;"), + "An absent service/server is conclusive only when the wider top-row list is not truncated."); + assertTrue(script.body().contains("if (!longCounts.has(identity)) return widerWindowIsComplete;"), + "A 24-hour category omitted by an untruncated 30-day list must invalidate the summaries."); + assertTrue(script.body().contains("shortCount > longCounts.get(identity)"), + "A larger 24-hour count for a shared service/server identity must invalidate the summaries."); + assertTrue(script.body().contains( + "dashboardCountRowsContradict(shortWindow?.topServices, longWindow?.topServices, 'service')")); + assertTrue(script.body().contains( + "dashboardCountRowsContradict(shortWindow?.topServers, longWindow?.topServers, 'server')")); + assertTrue(script.body().contains("dashboardInspectionStatus.voteLog24h = 'incomplete';\n dashboardInspectionStatus.voteLog30d = 'incomplete';")); + assertTrue(script.body().contains("entry.count > remaining")); + assertTrue(script.body().contains("const expectedStatuses = entry.enabled === false")); + assertTrue(script.body().contains("typeof entry.hasRewards !== 'boolean'"), + "Readable Vote Site rows must include a typed reward-presence field."); + assertTrue(script.body().contains("function validDashboardVoteSiteAggregate(entry, status)")); + assertTrue(script.body().contains( + "aggregateFields.some(field => Object.hasOwn(entry, field))"), + "Unreadable VoteLog rows must reject non-authoritative aggregate fields."); + assertTrue(script.body().contains("const loggedVotes = finiteCount(entry.loggedVotes);"), + "Readable Vote Site health rows must validate every aggregate as a nonnegative safe integer."); + assertTrue(script.body().contains("immediateVotes + cachedVotes !== loggedVotes"), + "Readable Vote Site health rows must preserve the logged/immediate/cached sum."); + assertTrue(script.body().contains("loggedVotes === 0 ? lastVoteTime !== 0 : lastVoteTime === 0"), + "A Vote Site last-vote timestamp must be zero exactly when its readable aggregate is empty."); + assertTrue(script.body().contains("status === 'ACTIVE' && loggedVotes === 0"), + "ACTIVE Vote Site rows must contain at least one logged vote."); + assertTrue(script.body().contains("status === 'NO_RECENT_VOTES' && loggedVotes !== 0"), + "NO_RECENT_VOTES rows must contain no logged votes."); + assertTrue(script.body().contains("function dashboardHealthContradictsVoteSummary(health, summary)")); + assertTrue(script.body().contains("function dashboardHealthServicesContradictSummary(sites, topServices)"), + "Vote Site health must compare each normalized service aggregate with the 30-day ranking."); + assertTrue(script.body().contains("const topServicesComplete = topServices.length < 20;"), + "A short top-services list is complete, so an omitted configured service is contradictory."); + assertTrue(script.body().contains("const topServiceCounts = new Map();")); + assertTrue(script.body().contains("return loggedVotes > 0 && topServicesComplete;"), + "A zero-vote site may be omitted from a complete positive-count ranking."); + assertTrue(script.body().contains( + "if (!topServiceCounts.has(serviceIdentity)) return loggedVotes > 0 && topServicesComplete;"), + "A configured service with votes omitted from a complete top-services list must invalidate the snapshot."); + assertTrue(script.body().contains("return loggedVotes > topServiceCounts.get(serviceIdentity);"), + "Per-service logged VoteLog counts must not exceed their 30-day ranking counts."); + assertTrue(script.body().contains( + "if (dashboardHealthServicesContradictSummary(health.sites, summary.topServices)) return true;"), + "Per-service health and 30-day summary contradictions must invalidate both inspections."); + assertTrue(script.body().contains("[['loggedVotes', 'total'], ['immediateVotes', 'immediate'], ['cachedVotes', 'cached']]"), + "Each readable per-site VoteLog aggregate must be bounded by its corresponding 30-day summary total."); + assertTrue(script.body().contains("siteCount > summaryCount"), + "Sequentially inconsistent per-site and overall VoteLog reads must be treated as incomplete."); + assertTrue(script.body().contains("function dashboardHealthAggregateExceedsSummary(sites, siteField, summaryCount)"), + "Retained readable VoteLog site aggregates must be bounded in aggregate by summary counters."); + assertTrue(script.body().contains("const countedServices = new Set();")); + assertTrue(script.body().contains("countedServices.has(serviceIdentity)"), + "Aliases sharing a canonical ServiceSite must not double-count the same VoteLog aggregate."); + assertTrue(script.body().contains("return serviceIdentity.length >= 64;"), + "Duplicate ServiceSite values at the node serialization bound must remain unchecked as possibly truncated."); + assertTrue(script.body().contains("aggregate += siteCount;")); + assertTrue(script.body().contains("const serviceAggregates = new Map();"), + "Aliases sharing a ServiceSite must be checked against the same aggregate snapshot."); + assertTrue(script.body().contains("function dashboardVoteSiteAggregatesMatch(left, right)"), + "ServiceSite aliases with contradictory aggregates must invalidate the health snapshot."); + assertTrue(script.body().contains("!dashboardVoteSiteAggregatesMatch(previousAggregate, aggregate)"), + "Contradictory ServiceSite aliases must not be silently deduplicated."); + assertTrue(script.body().contains( + "dashboardHealthAggregateExceedsSummary(\n health.sites, siteField, summaryCount)")); + assertTrue(script.body().contains("dashboardHealthContradictsVoteSummary(dashboardVoteSiteHealth, dashboardVoteSummary30d)")); + assertTrue(script.body().contains("dashboardInspectionStatus.voteSiteHealth = 'incomplete';\n dashboardInspectionStatus.voteLog30d = 'incomplete';"), + "Conflicting 30-day health and summary evidence must make both dashboard inspections unhealthy."); + assertTrue(script.body().contains("lastOverview = null;\n text(dataOverview, 'Refreshing server overview…');")); + assertTrue(script.body().contains("async function refreshOverview(target = dataOverview) {\n invalidateDashboardInspection();")); + assertTrue(script.body().contains(".result, 1);")); + assertTrue(script.body().contains("hasCount && hasVotes && count !== legacyCount")); + assertTrue(script.body().contains("days == null || days !== expectedDays || total == null")); + assertTrue(script.body().contains("topServices: services.items, topServers: servers.items")); + assertTrue(script.body().contains("typeof value === 'number' && Number.isSafeInteger(value) && value >= 0"), + "Dashboard counts must reject null, booleans, whitespace strings, and fractional values."); + assertFalse(script.body().contains("const count = Number(value);")); + assertFalse(script.body().contains("Number(dashboardOverview.configuredVoteSites) === 0")); + assertTrue(script.body().contains("const siteCountsKnown = configured != null && enabled != null;")); + assertTrue(script.body().contains("text(metricVoteSites, !siteCountsKnown ? '—'")); + assertTrue(script.body().contains( + "+ dashboardVoteSiteHealth.unmatchedLoggedServices.length"), + "Unmatched services must contribute to the Vote Sites warning count."); + assertFalse(script.body().contains( + "SERVICE_SITE_MISSING').slice(0, 10)"), + "All bounded missing ServiceSite entries must contribute to the health summary."); + assertFalse(script.body().contains( + "detectedUnconfiguredServices.slice(0, 10)"), + "All bounded detected-service entries must contribute to the health summary."); + assertFalse(script.body().contains( + "unmatchedLoggedServices.slice(0, 10)"), + "All bounded unmatched-service entries must contribute to the health summary."); + assertFalse(script.body().contains( + "['FAILED', 'COMPLETED_WITH_ERRORS'].includes(operation.state)).slice(0, 5)"), + "All bounded failed operations must contribute to the dashboard issue total."); + assertTrue(script.body().contains("runDriftCheck.addEventListener('click', async () => {\n setConfigView('compare');")); + assertTrue(script.body().contains("voteSitesConfigured: configuredVoteSites == null ? null : configuredVoteSites > 0")); + assertTrue(script.body().contains("voteSitesConfiguredKnown: configuredVoteSites != null")); + int exactShortcut = script.body().indexOf("const exactShortcut = GLOBAL_PAGE_SHORTCUTS.get(normalized);"); + int fuzzySetting = script.body().indexOf("const setting = SETTINGS_SCHEMA.find"); + assertTrue(exactShortcut >= 0 && exactShortcut < fuzzySetting); + assertTrue(script.body().contains("['rewards', {tab: 'quick-setup', scrollTarget: 'reward-builder-card'}]")); + assertTrue(script.body().contains("['vote sites', {tab: 'data', scrollTarget: 'site-health-card'}]")); + assertTrue(script.body().contains("['network doctor', {tab: 'network', scrollTarget: 'network-doctor-card'}]")); + assertTrue(script.body().contains("['configuration compare', {tab: 'configurations', configView: 'compare'")); + assertTrue(script.body().contains("openGlobalShortcut(GLOBAL_PAGE_SHORTCUTS.get('configuration compare'))")); + assertTrue(script.body().contains("globalSearchInput.value = '';\n globalSearchOptions.replaceChildren();")); + assertTrue(web.body().contains("data-tab=\"configurations\" data-config-shortcut=\"compare\"")); + assertTrue(script.body().contains("if (button.dataset.configShortcut) setConfigView(button.dataset.configShortcut);")); + assertTrue(script.body().contains("if (setting) {\n settingsFilter.value = query;")); + int openWorkspace = script.body().indexOf("function openWorkspace(tab, scrollTarget = '', preset = '', navigationButton = null)"); + int presetBeforeTab = script.body().indexOf("quickPreset.value = preset;", openWorkspace); + int activateAfterPreset = script.body().indexOf("setActiveTab(tab, true);", openWorkspace); + assertTrue(openWorkspace >= 0 && presetBeforeTab > openWorkspace && activateAfterPreset > presetBeforeTab, + "Nested shortcuts must establish their preset before tab autoload starts."); + assertTrue(script.body().contains("if (autoLoadInFlight.has(tab)) {\n autoLoadPending.add(tab);")); + assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);"), + "A preset change during an older read must queue a fresh autoload."); + assertTrue(script.body().contains("autoLoadPending.clear();")); + int globalShortcut = script.body().indexOf("function openGlobalShortcut(destination)"); + int selectConfigView = script.body().indexOf("setConfigView(destination.configView);", globalShortcut); + int openShortcutTab = script.body().indexOf("openWorkspace(destination.tab", globalShortcut); + assertTrue(globalShortcut >= 0 && selectConfigView > globalShortcut && openShortcutTab > selectConfigView, + "Nested search shortcuts must establish their subview before tab autoload starts."); + int globalNodeSearch = script.body().indexOf("if (node) {"); + int locateNodePage = script.body().indexOf( + "selectNodePage(Math.floor(nodePosition / PAGE_SIZE) * PAGE_SIZE);", globalNodeSearch); + int openServersForSearch = script.body().indexOf("openWorkspace('servers');", globalNodeSearch); + int selectServerForSearch = script.body().indexOf("selectPrimaryServer(node.nodeId);", globalNodeSearch); + int renderSearchedPage = script.body().indexOf("renderNodeViews();", selectServerForSearch); + assertTrue(globalNodeSearch >= 0 && locateNodePage > globalNodeSearch + && openServersForSearch > locateNodePage + && selectServerForSearch > openServersForSearch && renderSearchedPage > selectServerForSearch, + "Global server search must show the matching page, then navigate before selecting the node."); + int selectNodePage = script.body().indexOf("function selectNodePage(offset)"); + assertTrue(selectNodePage >= 0 + && script.body().indexOf("text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`);", + selectNodePage) > selectNodePage + && script.body().indexOf("previousPage.disabled = pageOffset === 0;", selectNodePage) + > selectNodePage + && script.body().indexOf( + "nextPage.disabled = pageOffset + visibleNodeItems.length >= allNodeItems.length;", + selectNodePage) > selectNodePage, + "Changing pages must keep the node range, page label, and navigation controls synchronized."); + assertFalse(script.body().contains(".style.")); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); HttpResponse health = get("/api/v1/health", null); assertEquals(200, health.statusCode()); @@ -258,6 +696,8 @@ class ControlHttpServerTest { JsonNode operations = json.readTree(get("/api/v1/operations", adminToken).body()); assertTrue(operations.get("items").isArray()); assertTrue(operations.get("voteLoggingRestartSessions").isObject()); + assertTrue(operations.get("configurationGeneration").canConvertToLong()); + assertTrue(operations.get("configurationGeneration").asLong() >= 0); assertError(get("/api/v1/health/anything", null), 404, "NOT_FOUND"); assertError(get("/api/v1/nodes/register/anything", null), 404, "NOT_FOUND"); HttpResponse method = send("POST", "/api/v1/health", "{}", null);