Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
34a686f
Redesign the Control dashboard shell
BenCodez Aug 31, 2026
64cacf2
Harden dashboard inspection state
BenCodez Sep 1, 2026
ac71f1d
Harden dashboard shortcuts and counts
BenCodez Sep 1, 2026
ae246c0
Queue nested dashboard autoloads
BenCodez Sep 1, 2026
38f87f5
Fix dashboard review findings
BenCodez Sep 7, 2026
6e2fa55
Fix dashboard review findings
BenCodez Sep 7, 2026
42c4930
Merge remote-tracking branch 'origin/codex/control-console-foundation…
BenCodez Sep 7, 2026
6f6b638
Harden dashboard inspection rendering
BenCodez Sep 7, 2026
17e4d33
Fix dashboard inspection refresh races
BenCodez Sep 7, 2026
3a01460
Harden dashboard search and summaries
BenCodez Sep 7, 2026
948a90c
Keep dashboard inspections consistent
BenCodez Sep 7, 2026
a99ed8f
Validate dashboard inspection consistency
BenCodez Sep 7, 2026
ab85f83
Invalidate inconsistent dashboard snapshots
BenCodez Sep 7, 2026
e42e00a
Prefer canonical VoteLog availability
BenCodez Sep 7, 2026
e2515ac
Merge remote-tracking branch 'origin/main' into codex/control-dashboa…
BenCodez Sep 8, 2026
52ac2b3
Validate vote-site health keys
BenCodez Sep 8, 2026
049c10a
fix: serialize dashboard refreshes
BenCodez Sep 8, 2026
131ee54
Harden dashboard refresh and responsive layout
BenCodez Sep 8, 2026
e397fa4
Refresh validated dashboard state
BenCodez Sep 8, 2026
f800153
Fix dashboard health failure state
BenCodez Sep 8, 2026
364357c
Harden dashboard evidence refresh
BenCodez Sep 8, 2026
4ad9f35
Harden service health normalization
BenCodez Sep 8, 2026
a2e7224
Align vote-site health warnings
BenCodez Sep 8, 2026
8fc70b5
Reject duplicate unmatched services
BenCodez Sep 8, 2026
00a136b
Validate vote-site health aggregates
BenCodez Sep 8, 2026
176887e
Cross-check vote site health totals
BenCodez Sep 8, 2026
741ea8a
Validate vote log category windows
BenCodez Sep 8, 2026
a90e377
Validate dashboard ranking consistency
BenCodez Sep 8, 2026
0deea84
Validate vote activity and anchor offsets
BenCodez Sep 8, 2026
3080ece
Reject empty vote category counts
BenCodez Sep 8, 2026
c3ec854
Require complete dashboard ranking totals
BenCodez Sep 8, 2026
41c61a5
Validate dashboard site aggregates
BenCodez Sep 8, 2026
f34668f
Inspect complete backend topology
BenCodez Sep 8, 2026
51cd916
Enforce vote-site service output bound
BenCodez Sep 8, 2026
5ab9e06
Reject zero-count dashboard categories
BenCodez Sep 8, 2026
768bd4f
Reject vote totals without voters
BenCodez Sep 8, 2026
a2af92c
Refresh dashboard topology before inspection
BenCodez Sep 8, 2026
b1bf799
Suppress recursive dashboard auto-load
BenCodez Sep 8, 2026
b369f92
Enforce dashboard identity wire limits
BenCodez Sep 8, 2026
9a93410
Refresh dashboard activity and access state
BenCodez Sep 8, 2026
fba1ad2
Refresh dashboard state safely
BenCodez Sep 8, 2026
3b988c2
Report unavailable enrollment health
BenCodez Sep 8, 2026
5bf5f34
Reject unreadable vote log aggregates
BenCodez Sep 8, 2026
a8ddf5b
Deduplicate backend health warnings
BenCodez Sep 8, 2026
fa7247d
Validate dashboard refresh snapshots
BenCodez Sep 8, 2026
0315afc
Reserve dashboard inspection during refresh
BenCodez Sep 9, 2026
251ebc1
Make dashboard retry refreshes awaitable
BenCodez Sep 9, 2026
a2f3567
Validate dashboard service vote totals
BenCodez Sep 9, 2026
0a2a330
Fix dashboard backend health checks
BenCodez Sep 9, 2026
7257e47
Fix dashboard issue navigation counts
BenCodez Sep 9, 2026
9390407
Count all failed dashboard operations
BenCodez Sep 9, 2026
6cba379
Correct dashboard health issue counts
BenCodez Sep 9, 2026
5576d27
Show searched servers on their registry page
BenCodez Sep 9, 2026
cf8a072
Render the searched server page
BenCodez Sep 9, 2026
a9a9dda
Enforce VoteLog service identity bounds
BenCodez Sep 9, 2026
e529790
Warn when all Vote Sites are disabled
BenCodez Sep 9, 2026
9e85685
Count disabled Vote Sites as needing attention
BenCodez Sep 9, 2026
3271fb7
Invalidate stale configuration views after applies
BenCodez Sep 9, 2026
e09ce16
Invalidate caches for each applied target
BenCodez Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/control-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public synchronized List<Entry> 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");
Expand Down Expand Up @@ -117,15 +117,22 @@ public synchronized State loadState() throws IOException {
}
result.sort(Comparator.comparing(Entry::createdAt));
Map<String, UUID> 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<Entry> entries) throws IOException {
save(entries, Map.of());
save(entries, Map.of(), 0L);
}

public synchronized void save(List<Entry> entries, Map<String, UUID> voteLoggingRestartSessions) throws IOException {
save(entries, voteLoggingRestartSessions, 0L);
}

public synchronized void save(List<Entry> entries, Map<String, UUID> 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<String, UUID> restartSessions = validateRestartSessions(voteLoggingRestartSessions);
Instant cutoff = clock.instant().minus(RETENTION);
List<Entry> filtered = entries.stream().filter(entry -> !entry.createdAt().isBefore(cutoff))
Expand All @@ -139,14 +146,16 @@ public synchronized void save(List<Entry> entries, Map<String, UUID> 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");

Expand Down Expand Up @@ -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<Entry> operations, Map<String, UUID> voteLoggingRestartSessions) { }
public record State(List<Entry> operations, Map<String, UUID> voteLoggingRestartSessions,
long configurationGeneration) {
public State(List<Entry> operations, Map<String, UUID> voteLoggingRestartSessions) {
this(operations, voteLoggingRestartSessions, 0L);
}
}

private record JournalFile(int schemaVersion, List<Entry> operations,
Map<String, UUID> voteLoggingRestartSessions) { }
Map<String, UUID> voteLoggingRestartSessions, long configurationGeneration) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public final class ConfigurationOperations implements AutoCloseable {
private final SecureRandom random = new SecureRandom();
private final LinkedHashMap<UUID, StoredOperation> operations = new LinkedHashMap<>();
private final LinkedHashMap<String, UUID> voteLoggingRestartSessions = new LinkedHashMap<>();
private long configurationGeneration;
private long retainedChangeBytes;
private long retainedMessageBytes;
private long retainedFileBytes;
Expand Down Expand Up @@ -163,7 +164,8 @@ public synchronized OperationListView listView() {
List<OperationView> 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. */
Expand Down Expand Up @@ -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<String, UUID> priorRestartSessions = new LinkedHashMap<>(voteLoggingRestartSessions);
try {
if (priorResult != null) releaseResultDetails(priorResult);
Expand All @@ -389,14 +392,16 @@ 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;
}
}

private void restoreTransition(StoredOperation operation, String nodeId, String priorState,
ConfigurationTaskResult priorResult, Instant priorLease, UUID priorAttempt,
long priorChanges, long priorMessages, long priorFiles,
long priorConfigurationGeneration,
Map<String, UUID> priorRestartSessions, RuntimeException failure) {
ConfigurationTaskResult current = operation.results.get(nodeId);
if (current != null && current != priorResult) releaseResultDetails(current);
Expand All @@ -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 {
Expand Down Expand Up @@ -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<String, UUID> 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);
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -903,7 +915,12 @@ public record OperationView(UUID operationId, String type, String state, Instant
Map<String, ConfigurationTaskResult> results, String approvalToken,
UUID sourceOperationId, boolean recovered, boolean retryable) { }

public record OperationListView(List<OperationView> items, Map<String, UUID> voteLoggingRestartSessions) { }
public record OperationListView(List<OperationView> items, Map<String, UUID> voteLoggingRestartSessions,
long configurationGeneration) {
public OperationListView(List<OperationView> items, Map<String, UUID> voteLoggingRestartSessions) {
this(items, voteLoggingRestartSessions, 0L);
}
}

private record ValidatedTargets(List<String> nodeIds, Map<String, String> platforms,
Map<String, UUID> sessions) { }
Expand Down
Loading
Loading