From 30a5b808e857e00e32786d0862f143a67f6c9683 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:57:58 -0600 Subject: [PATCH 1/6] Extend Control management foundation --- AGENTS.md | 17 + README.md | 17 +- docs/control-management.md | 31 +- .../domain/ConfigurationOperations.java | 40 +- .../control/domain/InMemoryNodeRegistry.java | 1 + .../protocol/ManagedConfiguration.java | 4 +- src/main/resources/web/app.js | 583 +++++++++++++++--- src/main/resources/web/index.html | 9 +- .../domain/ConfigurationOperationsTest.java | 57 ++ .../control/http/ControlHttpServerTest.java | 12 +- 10 files changed, 685 insertions(+), 86 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e71e52..be530aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ Requirements: JDK 17+ and Maven 3.9+. ```shell mvn -B clean verify node --check src/main/resources/web/app.js +# From the paired VotingPlugin/VotingPlugin checkout when protocol or node code changes: +mvn -B clean package ``` Use a focused Maven test while iterating, then run the complete command before opening a PR: @@ -42,6 +44,9 @@ The CI definition is `.github/workflows/maven.yml`. The shaded runnable artifact role, topology, and dependency cancellations at the state transition; do not wait for graceful shutdown. Quick-setup history uses an internal, non-serialized `redacted` marker, and proposal validation must reject that marker. - `domain/InspectionOperations` coordinates the separate, read-only `data.inspect.v1` lane. +- `config.proxy-files.v1` is the deliberately narrow proxy-file capability: it manages only a proxy node's + `bungeeconfig.yml`, through the same revision-bound READ/PREVIEW/one-time-APPLY operation model. It is not generic + proxy filesystem access. The paired node service is `VotingPlugin/.../proxy/control/ProxyConfigurationFileService`. - `domain/ConfigurationSnapshots` stores bounded copies of the redacted content returned by completed managed-file reads. A successful empty file remains a non-null empty document rather than being confused with omitted content. The list API omits content, full reads are admin-only, and durable files are owner-permissioned where the platform supports POSIX @@ -69,12 +74,19 @@ The CI definition is `.github/workflows/maven.yml`. The shaded runnable artifact exact versioned capability. Unknown advertised capabilities remain unaccepted. 4. Configuration writes follow `READ`/`PREVIEW`/`APPLY`. Apply consumes the one-time approval from a completely successful preview and carries the node revisions that were previewed. Do not create a shortcut around this workflow. + `bungeeconfig.yml` is proxy-only and requires `config.proxy-files.v1`; managed Bukkit files require + `config.files.v1`. Proxy-file reads mask secrets, preserve redaction markers/comments, reject unsafe YAML and symlinks, + and atomically stage, back up, and publish. General proxy-file changes may still require a proxy restart; do not report + them as hot-applied. 5. A claimed task has a two-minute lease and a unique `attemptId`. A result must echo the current node session, the exact session that claimed the lease, and the attempt; stale attempts or post-claim reconnects cannot complete work. Claim, completion, and capability-loss state transitions must roll back if their durable audit append fails. 6. The inspection lane is read-only. Its allow-listed kind and bounded string filters are the whole request; results are a structured JSON envelope whose serialized size is at most 512 KiB, and are retained only briefly. Audit the kind, never player names or other filter values. + Exact player inspection may expose only bounded, allow-listed stored VotingPlugin fields, never arbitrary database + columns, raw offline-vote payloads, or player enumeration. Adding player mutation requires a separate durable, + revisioned, capability-negotiated operation; do not turn inspection into an editor. 7. Mask passwords, credentials, tokens, API keys, authorization values, webhook secrets, and comparable secrets before a read leaves a node. Never put secrets, proposed file contents, approval tokens, or inspection filters in logs/audit. Configuration snapshots persist only the node's redacted read result; do not weaken masking, admin authorization, or @@ -122,6 +134,11 @@ when the pieces are independently deployable or need materially different review Protocol version `1` describes the registration/heartbeat resource protocol. Feature evolution normally uses a new capability such as `data.inspect.v1`; do not bump the whole protocol for an optional additive feature. +`config.proxy-method.v1` persists a validated method on the proxy and reported backends. Backends reload only their proxy +communication handler. The proxy acknowledges the durable result before its existing guarded runtime replacement, so +teardown cannot lose the completion. A failed backend reload must restore its backup. Control availability or a method +switch must never delay or prevent normal proxy or backend startup. + ## Safe change checklist - Identify the trust boundary and maximum sizes before adding the happy path. diff --git a/README.md b/README.md index ca259f9..bfc51a9 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,10 @@ Control records its own observation time; it does not trust remote wall-clock ti Configuration is split into independently negotiated capabilities. `config.proxy-routing.v1` exposes typed proxy routing. `config.files.v1` manages `Config.yml`, `VoteSites.yml`, `SpecialRewards.yml`, `GUI.yml`, `Shop.yml`, and -`BungeeSettings.yml` on enrolled Bukkit nodes through a bounded YAML editor. `config.quick-setup.v1` supplies standalone, +`BungeeSettings.yml` on enrolled Bukkit nodes through a bounded YAML editor. `config.proxy-files.v1` is separate and +manages only an enrolled proxy's `bungeeconfig.yml`; it is not general proxy file access. The proxy file is strict, +bounded, redacted YAML with safe preview/approval, atomic publication, and a local backup. General proxy-file settings +are saved without a proxy reload, so the operation reports that a restart is needed to activate them. `config.quick-setup.v1` supplies standalone, proxy-backend, vote-site, easy-reward, common-settings, auto-create-vote-sites, vote-logging, vote-party, and typed reward-builder presets. The auto-create preset owns only `AutoCreateVoteSites`; the logging preset owns only enabled state, purge retention @@ -198,8 +201,9 @@ explicitly included in configuration changes, so one slow secondary node does no `data.inspect.v1` is a separate read-only lane for overview, vote-site health (including persisted unconfigured service observations), exact-player data, bounded VoteLog summary/search/correlation trace, non-creating service-site resolution, no-side-effect reward simulation, and redacted diagnostics. It accepts only allow-listed string filters and bounded result -schemas; there is no raw SQL, arbitrary player -enumeration, command execution, generic file/database browsing, or write operation. VoteLog output is labeled **logged +schemas; exact player results may include only a bounded allow-list of safe stored VotingPlugin fields when available. +There is no raw SQL, arbitrary player enumeration or editing, command execution, generic file/database browsing, or write +operation. VoteLog output is labeled **logged events**, not a complete network delivery trace. Overview/diagnostics expose a bounded `voteLogReadable` probe; summary/search/trace fail `UNAVAILABLE` when that probe fails, while vote-site health labels SQL data unavailable/unreadable instead of turning a query failure into “no recent votes.” @@ -216,6 +220,13 @@ backup if reload fails. The WebUI labels a rolled-back node as not saved and dis the plugin. Bukkit reads normalize YAML; administrators should expect comments and formatting to be normalized when applying through the full editor. +The `proxy-method` guided preset is intentionally different from a normal proxy configuration save. Following preview and +approval, VotingPlugin preflights and persists the selected communication method across the reported network. Backends +reload only their proxy communication handler. The proxy first acknowledges its durable result and then replaces its +runtime, preventing teardown from losing the operation result. A persistence or backend handler reload failure restores +the local backup; resolve the reported node failure and preview again rather than assuming a network-wide method change +was committed. + `configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. A cross-process lifetime lock prevents two Control processes from forking the same audit chain. Both the active and retained segment are verified against a durable atomic tail/count checkpoint before startup accepts new diff --git a/docs/control-management.md b/docs/control-management.md index e5cec99..6640177 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -19,6 +19,15 @@ Connectors always initiate outbound HTTP(S) to Control. No Control feature adds One node credential is bound to one stable node ID. Browser sessions and the API automation credential are separate from node credentials. +## Paired development verification + +For a Control-only change, run the focused tests first and then `mvn -B clean verify` in this repository. Any change to +the negotiated capability map, a node task/result, proxy transport activation, or inspection schema also requires the +paired `VotingPlugin/VotingPlugin` checkout to pass its full `mvn -B clean package` verification. Run `git diff --check` +in every modified repository; run `node --check src/main/resources/web/app.js` when the static WebUI changes. Control +must remain optional throughout these checks: an unavailable or older peer must reject the new capability cleanly, never +block normal vote processing or server/proxy startup. + ## Capability map Capability negotiation is the compatibility boundary. A node advertises capabilities during registration/heartbeat; @@ -29,12 +38,13 @@ Control accepts only the intersection with its own allow-list. | `discovery.read` | Current node identity and status | | `presence.snapshot` | Full replacement backend presence snapshots from a proxy | | `config.proxy-routing.v1` | `SendVotesToAllServers` and `BlockedServers` on a proxy | +| `config.proxy-files.v1` | Revisioned, redacted management of that proxy's single `bungeeconfig.yml` file | | `config.files.v1` | Bounded reads/previews/applies for managed Bukkit YAML files | | `config.file-comments.v1` | Preserves Control-managed comment metadata where supported | | `config.quick-setup.v1` | Typed guided settings and reward/site presets | | `config.vote-sites-sync.v1` | Reward-safe VoteSites merge from one backend to selected targets | | `config.transport-test.v1` | Typed, bounded proxy-to-backend communication check | -| `config.proxy-method.v1` | Coordinated preview/apply of a supported network proxy method | +| `config.proxy-method.v1` | Coordinated preview/apply and acknowledged runtime replacement for a supported network proxy method | | `data.inspect.v1` | Typed read-only data, health, simulation, and diagnostics requests | Do not infer support from plugin version strings. Check `acceptedCapabilities` for the exact capability. @@ -50,6 +60,7 @@ authenticated, CSRF-protected endpoint and node capability checks as an external | 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 | | Fast file reads | Caches a successful file read for 30 seconds by node ID, node session, and file | Browser memory only; cleared on logout and successful relevant writes; session binding prevents reuse after reconnect | +| Proxy configuration | Opens `bungeeconfig.yml` only for the selected online proxy that negotiated `config.proxy-files.v1` | Fixed one-file capability, never proxy file browsing; redacted READ, PREVIEW, and one-time approved APPLY still apply | | Configuration drift | Reads the same redacted managed file from two or more selected capable nodes, groups exact revisions, and compares each target with the first successful baseline | Read-only; renders at most 50 differing line pairs per target and truncates each redacted line to 200 characters | | Snapshots | Creates a named durable snapshot from the last completed file read and loads one document into the editor | Stores the full redacted read result; restore is proposed content and must be freshly previewed/approved | | Settings catalog | Filters a static schema of commonly managed setting key, file, type, default, and effect | Reference for guided forms, not a generic setting API or claim to cover every VotingPlugin option | @@ -73,7 +84,8 @@ values should be loaded before modifying an existing configuration. - `proxy-routing` manages only `sendVotesToAllServers` and `blockedServers`; - `file` manages one allow-listed file: `Config.yml`, `VoteSites.yml`, `SpecialRewards.yml`, `GUI.yml`, `Shop.yml`, - `BungeeSettings.yml`, or one validated split file under `VoteSites/`; + `BungeeSettings.yml`, or one validated split file under `VoteSites/` on a Bukkit node. A proxy node can separately + manage only its fixed `bungeeconfig.yml` when it negotiated `config.proxy-files.v1`; - `quick-setup` manages one typed preset with at most 20 bounded string options. Ordinary options are at most 500 UTF-8 bytes; the internal VoteSites sync source may be 512 KiB and a `reward-builder` proposal may be 64 KiB. Both large inputs are stripped from every public operation view. @@ -95,6 +107,11 @@ proposal actions/messages. File content is limited to 512 KiB. Node results mask secret-like YAML paths. A replacement secret may pass through an authenticated proposal, but Control omits file proposal contents from operation views and never records them in its audit. +`bungeeconfig.yml` is strict UTF-8 safe YAML with duplicate keys, aliases, merge keys, symlinks, and oversized content +rejected. Its comment-aware editor preserves existing comments/formatting where possible and keeps redacted secret values +and their markers safe. It stages a permission-preserving backup and an atomic replacement before reporting a save. A +normal proxy-file save intentionally does not reload or restart the proxy: the result must tell the administrator that +general setting activation still requires a proxy restart. ### Read, preview, and apply @@ -106,6 +123,12 @@ authenticated proposal, but Control omits file proposal contents from operation 4. Nodes stage and atomically replace managed YAML, reload VotingPlugin, and restore the local `.control-backup` if reload fails. The result distinguishes reload and rollback from a successful save. +The proxy method preset validates and persists the requested `MYSQL`, `PLUGINMESSAGING`, `REDIS`, `MQTT`, or `SOCKETS` +method on the proxy and its reported backends. The proxy acknowledges its durable result before replacing its runtime, so +the operation result cannot be lost during teardown. Backends reload only their proxy communication handler. If a durable +write or backend handler reload fails, its local backup is restored and the operation reports the failed/rolled-back state +rather than a false network-wide success. + Each target state is `QUEUED`, `IN_PROGRESS`, or `COMPLETE`; the aggregate state is `RUNNING`, `SUCCEEDED`, or `COMPLETED_WITH_ERRORS`. A claim has a two-minute lease and new `attemptId`. The result must echo that attempt and the current node session, preventing a stale execution from completing reissued work. @@ -261,7 +284,7 @@ task is string-valued and VotingPlugin's handler validates text after selecting | --- | --- | --- | | `overview` | none | Plugin/platform versions; configuration health; bounded data-storage mode; proxy mode; vote-site counts; auto-create state; configured/available/readable VoteLog state | | `vote-site-health` | string `days` 1–365, default 30 | Configured site state, bounded logged aggregates, unmatched logged services, and bounded persisted service observations with no configured match | -| `player` | exactly one of `name` (1–16 characters) or `uuid` (canonical 36-character UUID) | Exact existing-player lookup; totals, points, streaks, up to 100 per-site last-vote rows, and backend pending-offline count; never player enumeration | +| `player` | exactly one of `name` (1–16 characters) or `uuid` (canonical 36-character UUID) | Exact existing-player lookup; totals, points, streaks, up to 100 per-site last-vote rows, backend pending-offline count, and a bounded read-only allow-list of stored VotingPlugin fields when storage is available; never player enumeration or editing | | `vote-log-summary` | string `days` 1–365, default 30 | Vote count, immediate/cached split, unique voters, and top 20 services/servers | | `vote-log-search` | at most one of exact `player` (1–16 characters), `service` (1–64), or `server` (1–64); optional `event` and string `days`/`limit` | Most recent bounded logged-event rows; default 25 and maximum 100 | | `vote-trace` | required canonical 36-character UUID `voteId`; optional string `days`/`limit` | Chronological logged events sharing one correlation ID; default 50 and maximum 100 | @@ -285,7 +308,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. Last-vote rows contain `siteKey`, `displayName`, `serviceSite`, and `time`, and include only stored keys that currently resolve as enabled sites | +| `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-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` | 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 c277886..41d4597 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -28,6 +28,7 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String CAPABILITY = "config.proxy-routing.v1"; public static final String FILE_CAPABILITY = "config.files.v1"; + public static final String PROXY_FILE_CAPABILITY = "config.proxy-files.v1"; public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1"; public static final String VOTE_SITES_SYNC_CAPABILITY = "config.vote-sites-sync.v1"; public static final String TRANSPORT_TEST_CAPABILITY = "config.transport-test.v1"; @@ -84,13 +85,16 @@ public synchronized OperationView createRead(List nodeIds, ManagedConfig throw invalid("reward builder is preview/apply only"); } selector.validateProposal(); - return create("READ", validateTargets(nodeIds, selector.capability()), selector, null); + ValidatedTargets targets = validateTargets(nodeIds, selector.capability()); + validateConfigurationTargets(targets, selector); + return create("READ", targets, selector, null); } public synchronized OperationView createPreview(List nodeIds, ManagedConfiguration configuration) { if (configuration == null) throw invalid("configuration is required"); configuration.validateProposal(); ValidatedTargets targets = validateTargets(nodeIds, configuration.capability()); + validateConfigurationTargets(targets, configuration); validateProxyMethodTargets(targets, configuration); byte[] token = new byte[32]; random.nextBytes(token); @@ -117,6 +121,7 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok ValidatedTargets targets = validateTargets(new ArrayList<>(preview.states.keySet()), preview.configuration.capability()); validateApprovedTargets(preview, targets); + validateConfigurationTargets(targets, preview.configuration); validateProxyMethodTargets(targets, preview.configuration); rejectOverlappingProxyMethodApply(targets, preview.configuration); rejectOverlappingVoteLoggingApply(targets, preview.configuration); @@ -185,6 +190,7 @@ public synchronized OperationView retry(UUID id) { List requested = "PREVIEW".equals(original.type) ? new ArrayList<>(original.states.keySet()) : failed; ValidatedTargets targets = validateTargets(requested, original.configuration.capability()); + validateConfigurationTargets(targets, original.configuration); if ("APPLY".equals(original.type)) { validateApprovedTargets(original, targets); rejectOverlappingVoteLoggingApply(targets, original.configuration); @@ -230,6 +236,7 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { Instant leased = operation.leasedAt.get(nodeId); if ("QUEUED".equals(state) || ("IN_PROGRESS".equals(state) && leased != null && !now.isBefore(leased.plus(LEASE)))) { + if (cancelChangedFileRole(operation, node)) continue; if (cancelChangedProxyMethodRole(operation, node)) continue; if (deferProxyMethodApply(operation, node)) continue; if (!node.online() || !node.acceptedCapabilities().contains(operation.configuration.capability())) { @@ -338,6 +345,21 @@ private boolean cancelChangedProxyMethodRole(StoredOperation operation, NodeStat return true; } + private boolean cancelChangedFileRole(StoredOperation operation, NodeStatus node) { + if (!ManagedConfiguration.FILE.equals(operation.configuration.domain())) return false; + String expectedPlatform = operation.targetPlatforms.get(node.nodeId()); + boolean proxyFile = "bungeeconfig.yml".equals(operation.configuration.fileName()); + boolean currentRoleMatches = proxyFile + ? !"BUKKIT".equalsIgnoreCase(node.platform()) + : "BUKKIT".equalsIgnoreCase(node.platform()); + if (expectedPlatform != null && expectedPlatform.equalsIgnoreCase(node.platform()) && currentRoleMatches) { + return false; + } + automaticCancellation(operation, node.nodeId(), sessionId(node), "TARGET_CHANGED", + "Node platform changed after the task was created; create it again", "TARGET_ROLE_CHANGED"); + return true; + } + /** * Commits an automatic task completion only after the redacted journal is durable. Both the * operation maps and retention counters are restored if either durable step fails, leaving the @@ -438,6 +460,22 @@ private void validateProxyMethodTargets(ValidatedTargets targets, ManagedConfigu } } + private static void validateConfigurationTargets(ValidatedTargets targets, + ManagedConfiguration configuration) { + if (!ManagedConfiguration.FILE.equals(configuration.domain())) return; + boolean proxyFile = "bungeeconfig.yml".equals(configuration.fileName()); + List invalid = targets.nodeIds().stream() + .filter(nodeId -> proxyFile + ? "BUKKIT".equalsIgnoreCase(targets.platforms().get(nodeId)) + : !"BUKKIT".equalsIgnoreCase(targets.platforms().get(nodeId))) + .toList(); + if (!invalid.isEmpty()) { + throw new ValidationException("INVALID_TARGET", + proxyFile ? "Proxy configuration files require proxy nodes" + : "Backend configuration files require Bukkit nodes", invalid); + } + } + private static void validateApprovedTargets(StoredOperation preview, ValidatedTargets current) { for (String nodeId : current.nodeIds()) { if (!Objects.equals(preview.targetSessions.get(nodeId), current.sessions().get(nodeId)) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index e0d42ec..176c010 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -27,6 +27,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot", ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY, + ConfigurationOperations.PROXY_FILE_CAPABILITY, ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, "config.file-comments.v1", InspectionQuery.CAPABILITY); diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index d0d22a0..407ed31 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -125,7 +125,7 @@ public void validateProposal() { public String capability() { return switch (domain) { case PROXY_ROUTING -> "config.proxy-routing.v1"; - case FILE -> "config.files.v1"; + case FILE -> "bungeeconfig.yml".equals(fileName) ? "config.proxy-files.v1" : "config.files.v1"; case QUICK_SETUP -> VOTE_SITES_SYNC.equals(preset) ? "config.vote-sites-sync.v1" : COMMUNICATION_TEST.equals(preset) ? "config.transport-test.v1" : PROXY_METHOD.equals(preset) @@ -161,7 +161,7 @@ private static int utf8Bytes(String value) { private static void validateFileName(String value) { if (value == null || value.length() > 160 - || !value.matches("(?:Config|VoteSites|SpecialRewards|GUI|Shop|BungeeSettings)\\.yml|VoteSites/[A-Za-z0-9._-]{1,100}\\.yml")) { + || !value.matches("(?:Config|VoteSites|SpecialRewards|GUI|Shop|BungeeSettings)\\.yml|bungeeconfig\\.yml|VoteSites/[A-Za-z0-9._-]{1,100}\\.yml")) { throw new IllegalArgumentException("configuration file name is not managed"); } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 3790e4a..99511ee 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -202,6 +202,8 @@ const PAGE_SIZE = 100; const MAX_CONFIGURATION_TARGETS = 100; const MAX_SYNC_TARGETS = 100; const MAX_OPERATION_TARGETS = 100; +const MAX_TRACE_NODES = 12; +const TRACE_DEADLINE_MS = 90_000; const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; let csrfToken = ''; @@ -246,6 +248,9 @@ const SETUP_PROFILE_KEY = 'votingplugin-control.setup-profiles.v1'; let fileReadCache = new Map(); let lastFileReadOperation = null; let configurationContentPresent = false; +let configurationDirty = false; +let routingDirty = false; +let configurationFileSelection = configurationFile.value; let inspectionInFlight = false; let lastDiagnostics = null; let lastOverview = null; @@ -253,6 +258,7 @@ let operationHistoryItems = []; let dedicatedSetupApprovals = new Map(); let pendingDetectedVoteSite = null; let voteLoggingRestartPending = new Map(); +let autoLoadInFlight = new Set(); function text(element, value) { element.textContent = value; @@ -279,6 +285,32 @@ function inspectionCapableNode() { return node?.online && node.acceptedCapabilities.includes('data.inspect.v1') ? node : null; } +function connectedInspectionNodes() { + return allNodeItems.filter(node => node.online && isBackend(node) + && node.acceptedCapabilities.includes('data.inspect.v1')); +} + +function selectedFileCapability(fileName = configurationFile.value) { + return fileName === 'bungeeconfig.yml' ? 'config.proxy-files.v1' : 'config.files.v1'; +} + +function fileTargetsForSelection(fileName = configurationFile.value) { + const capability = selectedFileCapability(fileName); + const selected = nodeIndex.get(selectedServerId); + if (capability === 'config.proxy-files.v1') { + return selected?.online && isProxy(selected) && selected.acceptedCapabilities.includes(capability) + ? [selected.nodeId] : []; + } + return targets(capability).filter(nodeId => isBackend(nodeIndex.get(nodeId))); +} + +function fileTargetDescription(fileName = configurationFile.value) { + const targetsForFile = fileTargetsForSelection(fileName); + if (fileName === 'bungeeconfig.yml') return targetsForFile.length + ? `the selected proxy (${targetsForFile[0]})` : 'the selected proxy'; + return `every selected Bukkit node`; +} + function boundedLines(value, maximum = 20) { const lines = value.split(/\r?\n/).map(item => item.trim()).filter(Boolean); if (lines.length > maximum) throw new Error(`At most ${maximum} reward lines are allowed.`); @@ -320,6 +352,83 @@ function renderJsonResult(element, value, emptyMessage = 'No data returned.') { element.append(pre); } +function formatEpoch(value) { + const epoch = Number(value); + return Number.isFinite(epoch) && epoch > 0 ? new Date(epoch).toLocaleString() : 'Unknown'; +} + +function renderPlayerData(value) { + playerResult.replaceChildren(); + if (!value || value.found !== true) { + text(playerResult, value?.found === false ? 'Player not found.' : 'No player queried.'); + return; + } + const summary = document.createElement('p'); + summary.append(text(document.createElement('strong'), `${value.name || 'Unknown'} · ${value.uuid || 'Unknown'}`)); + const storage = typeof value.storage === 'string' ? ` · ${value.storage}` + : value.storageRowAvailable === false ? ' · stored row unavailable' : ''; + const columnCount = Array.isArray(value.columns) ? ` · ${value.columns.length} stored columns` : ''; + summary.append(document.createTextNode(`${storage}${columnCount}`)); + playerResult.append(summary); + const profile = document.createElement('dl'); + profile.className = 'detail-list'; + const add = (label, detail) => { + profile.append(text(document.createElement('dt'), label)); + profile.append(text(document.createElement('dd'), detail)); + }; + const totals = value.totals && typeof value.totals === 'object' ? value.totals : {}; + add('Online', value.online === true ? 'Yes' : 'No'); + add('Votes', `Daily ${totals.daily ?? 'Unknown'} · Weekly ${totals.weekly ?? 'Unknown'} · Monthly ${totals.monthly ?? 'Unknown'} · All-time ${totals.allTime ?? 'Unknown'}`); + add('Points', value.points ?? 'Unknown'); + add('Last vote', formatEpoch(value.lastVoteTime)); + add('Last online', formatEpoch(value.lastOnline)); + playerResult.append(profile); + if (!Array.isArray(value.columns)) return; + const scroll = document.createElement('div'); + scroll.className = 'table-scroll'; + const table = document.createElement('table'); + const head = document.createElement('thead'); + const headRow = document.createElement('tr'); + ['Column', 'Storage type', 'Exact stored value'].forEach(label => headRow.append(text(document.createElement('th'), label))); + head.append(headRow); + const body = document.createElement('tbody'); + value.columns.forEach(column => { + const row = document.createElement('tr'); + row.append(text(document.createElement('td'), column.name)); + row.append(text(document.createElement('td'), column.type)); + const value = document.createElement('td'); + const code = document.createElement('code'); + text(code, column.value); + value.append(code); + row.append(value); + body.append(row); + }); + table.append(head, body); + scroll.append(table); + playerResult.append(scroll); + if (value.columnsTruncated === true) { + const warning = document.createElement('p'); + warning.className = 'warning-text'; + text(warning, 'Some stored values were omitted by the bounded, allow-listed inspection contract.'); + playerResult.append(warning); + } +} + +async function loadPlayerData(value) { + const request = value.trim(); + const filters = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(request) + ? {uuid: request} : {name: request}; + if (!filters.uuid && !/^[A-Za-z0-9_]{1,16}$/.test(request)) { + throw new Error('Enter a complete Minecraft player name or canonical UUID.'); + } + try { + renderPlayerData((await runInspection('player', filters, playerResult)).result); + } catch (error) { + text(playerResult, error.message); + throw error; + } +} + function renderSiteHealthResult(value) { renderJsonResult(siteHealthResult, value); const services = Array.isArray(value?.detectedUnconfiguredServices) @@ -415,6 +524,168 @@ async function runInspection(kind, filters = {}, statusElement = null) { } } +async function runInspectionOnNode(node, kind, filters = {}, options = {}) { + const nodeId = node?.nodeId; + const sessionId = node?.sessionId; + if (!nodeId || !sessionId) throw new Error('The inspection node is unavailable.'); + const requestAuthenticationGeneration = authenticationGeneration; + const boundedFilters = {}; + Object.entries(filters).forEach(([key, value]) => { + const serialized = String(value); + if (new TextEncoder().encode(serialized).length > 500) throw new Error(`${key} exceeds the bounded inspection limit.`); + boundedFilters[key] = serialized; + }); + if (options.manageBusy !== false) { + inspectionInFlight = true; + updateExtendedButtons(); + } + try { + let inspection = await authorized('/api/v1/inspections', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({nodeId, query: {kind, filters: boundedFilters}}) + }); + const requestedDeadline = Number(options.deadlineAt); + const deadline = Number.isFinite(requestedDeadline) + ? Math.min(Date.now() + 180_000, requestedDeadline) + : Date.now() + 180_000; + while (inspection.state === 'RUNNING') { + if (Date.now() >= deadline) throw new Error('Inspection did not finish within this request budget.'); + await new Promise(resolve => window.setTimeout(resolve, Math.min(1000, Math.max(0, deadline - Date.now())))); + inspection = await authorized(`/api/v1/inspections/${inspection.inspectionId}`); + } + if (requestAuthenticationGeneration !== authenticationGeneration || sessionId !== nodeIndex.get(nodeId)?.sessionId) { + throw new Error('The node reconnected or the session changed while the trace ran.'); + } + if (inspection.state !== 'SUCCEEDED' || !inspection.result?.success) { + throw new Error(inspection.result?.message || inspection.result?.code || 'Inspection failed.'); + } + let envelope = inspection.result.data; + if (typeof envelope === 'string') { + try { envelope = JSON.parse(envelope); } catch (_) { throw new Error('The node returned malformed inspection data.'); } + } + if (envelope?.schemaVersion !== 1 || envelope.kind !== kind || !Object.hasOwn(envelope, 'result')) { + throw new Error('The node returned an unsupported inspection schema.'); + } + return envelope; + } finally { + if (options.manageBusy !== false) { + inspectionInFlight = false; + updateExtendedButtons(); + } + } +} + +function traceEventKey(event) { + return ['voteId', 'voteTime', 'playerUuid', 'playerName', 'service', 'server', 'event', 'context', 'status', 'cachedTotal'] + .map(key => String(event?.[key] ?? '')).join('\u0000'); +} + +function renderVoteTrace(trace) { + voteTraceResult.replaceChildren(); + const summary = document.createElement('p'); + text(summary, `${trace.events.length} unique retained ${trace.events.length === 1 ? 'event' : 'events'} from ${trace.sources.length} readable ${trace.sources.length === 1 ? 'node' : 'nodes'}.`); + voteTraceResult.append(summary); + if (trace.events.length) { + const scroll = document.createElement('div'); + scroll.className = 'table-scroll'; + const table = document.createElement('table'); + const head = document.createElement('thead'); + const row = document.createElement('tr'); + ['Time', 'Event', 'Status', 'Player', 'Service', 'Server', 'Context', 'Source nodes'].forEach(label => row.append(text(document.createElement('th'), label))); + head.append(row); + const body = document.createElement('tbody'); + trace.events.forEach(item => { + const event = item.event; + const eventRow = document.createElement('tr'); + const time = Number(event.voteTime); + eventRow.append(text(document.createElement('td'), Number.isFinite(time) && time > 0 ? new Date(time).toLocaleString() : 'Unknown')); + eventRow.append(text(document.createElement('td'), event.event || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.status || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.playerName || event.playerUuid || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.service || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.server || 'Unknown')); + eventRow.append(text(document.createElement('td'), event.context || '')); + eventRow.append(text(document.createElement('td'), item.sources.join(', '))); + body.append(eventRow); + }); + table.append(head, body); + scroll.append(table); + voteTraceResult.append(scroll); + } + const source = document.createElement('p'); + text(source, `Readable sources: ${trace.sources.join(', ') || 'none'}.`); + voteTraceResult.append(source); + if (trace.unavailable.length) { + const diagnostics = document.createElement('p'); + diagnostics.className = 'warning-text'; + text(diagnostics, `Unavailable or failed sources: ${trace.unavailable.join('; ')}.`); + voteTraceResult.append(diagnostics); + } + const boundary = document.createElement('p'); + boundary.className = 'warning-text'; + text(boundary, 'This is the complete retained trace returned by the readable nodes above. It cannot show an internal hop that no node recorded.'); + voteTraceResult.append(boundary); +} + +async function traceVoteAcrossNodes() { + const available = connectedInspectionNodes(); + const candidates = available.slice(0, MAX_TRACE_NODES); + if (candidates.length === 0) throw new Error('No connected backend supports vote-log inspection.'); + const voteId = voteTraceId.value.trim(); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(voteId)) { + throw new Error('Enter a canonical vote UUID.'); + } + const requestAuthenticationGeneration = authenticationGeneration; + const requestInputGeneration = inputGeneration; + const requestSelectedNodeId = selectedServerId; + const requestSelectedSessionId = nodeIndex.get(requestSelectedNodeId)?.sessionId; + const days = String(voteLogDays.value); + const candidateSessions = new Map(candidates.map(node => [node.nodeId, node.sessionId])); + const traceDeadline = Date.now() + TRACE_DEADLINE_MS; + const contextCurrent = () => requestAuthenticationGeneration === authenticationGeneration + && requestInputGeneration === inputGeneration && requestSelectedNodeId === selectedServerId + && requestSelectedSessionId === nodeIndex.get(requestSelectedNodeId)?.sessionId + && days === String(voteLogDays.value) + && candidates.every(node => candidateSessions.get(node.nodeId) === nodeIndex.get(node.nodeId)?.sessionId); + const events = new Map(); + const sources = []; + const unavailable = []; + if (available.length > candidates.length) unavailable.push(`${available.length - candidates.length} additional capable nodes were omitted by the ${MAX_TRACE_NODES}-node trace limit`); + inspectionInFlight = true; + updateExtendedButtons(); + text(voteTraceResult, `Collecting retained events from ${candidates.length} connected backend ${candidates.length === 1 ? 'node' : 'nodes'}…`); + try { + for (const node of candidates) { + if (!contextCurrent() || Date.now() >= traceDeadline) break; + try { + const envelope = await runInspectionOnNode(node, 'vote-trace', {voteId, days, limit: '100'}, + {deadlineAt: traceDeadline, manageBusy: false}); + const listed = Array.isArray(envelope.result?.events) ? envelope.result.events : []; + sources.push(`${node.displayName} (${node.nodeId})`); + listed.forEach(event => { + if (!event || typeof event !== 'object') return; + const key = traceEventKey(event); + const retained = events.get(key) || {event, sources: []}; + retained.sources.push(`${node.displayName} (${node.nodeId})`); + events.set(key, retained); + }); + } catch (error) { + unavailable.push(`${node.displayName} (${node.nodeId}): ${error.message || 'inspection failed'}`); + } + } + if (!contextCurrent()) return; + if (Date.now() >= traceDeadline) unavailable.push('The remaining nodes were omitted after the 90-second trace budget expired'); + const ordered = [...events.values()].map(item => ({...item, sources: [...new Set(item.sources)].sort()})).sort((left, right) => + Number(left.event.voteTime || 0) - Number(right.event.voteTime || 0) + || String(left.event.event || '').localeCompare(String(right.event.event || '')) + || String(left.event.server || '').localeCompare(String(right.event.server || ''))); + renderVoteTrace({events: ordered, sources, unavailable}); + } finally { + inspectionInFlight = false; + updateExtendedButtons(); + } +} + function operationPhase(operation) { if (operation.recovered && operation.state !== 'RUNNING') return `Recovered history · ${operation.state}`; if (operation.state === 'RUNNING') return 'Queued or running'; @@ -676,6 +947,10 @@ function applyAuthenticatedSession(body) { pendingDetectedVoteSite = null; configurationContent.value = ''; configurationContentPresent = false; + configurationDirty = false; + routingDirty = false; + configurationFileSelection = configurationFile.value; + autoLoadInFlight.clear(); inputGeneration++; logout.hidden = false; authCard.hidden = true; @@ -690,8 +965,7 @@ function applyAuthenticatedSession(body) { } function isProxy(node) { - return ['VELOCITY', 'BUNGEECORD'].includes(String(node.platform).toUpperCase()) || - node.acceptedCapabilities.includes('config.proxy-routing.v1'); + return ['VELOCITY', 'BUNGEECORD'].includes(String(node.platform).toUpperCase()); } function isBackend(node) { @@ -713,6 +987,7 @@ function platformLabel(platform) { function friendlyCapability(capability) { return ({ 'config.files.v1': 'Full configuration', + 'config.proxy-files.v1': 'Proxy configuration', 'config.file-comments.v1': 'Comments preserved', 'config.vote-sites-sync.v1': 'VoteSites sync', 'config.transport-test.v1': 'Communication test', @@ -795,7 +1070,7 @@ function nodeCard(node) { selector.className = 'node-select'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; - const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1'] + const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1'] .some(capability => node.acceptedCapabilities.includes(capability)); checkbox.disabled = !node.online || !controllable || node.nodeId === selectedServerId; checkbox.checked = selectedNodes.has(node.nodeId); @@ -848,12 +1123,74 @@ function setActiveTab(tab, updateHash = false) { tabButtons.forEach(button => button.setAttribute('aria-selected', String(button.dataset.tab === tab))); tabPanels.forEach(panel => { panel.hidden = panel.dataset.panel !== tab; }); if (updateHash && window.location.hash !== `#${tab}`) window.history.replaceState(null, '', `#${tab}`); + void autoLoadTab(tab); } function setConfigView(view) { if (!configViewPanels.some(panel => panel.dataset.configPanel === view)) view = 'easy'; configViewButtons.forEach(button => button.classList.toggle('active', button.dataset.configView === view)); configViewPanels.forEach(panel => { panel.hidden = panel.dataset.configPanel !== view; }); + if (view === 'yaml') void autoLoadTab('configurations'); +} + +function resetFileEditorForSelection(message) { + configurationContent.value = ''; + configurationContentPresent = false; + configurationDirty = false; + lastFileReadOperation = null; + approvedFilePreview = null; + updateEditorPosition(); + text(fileOperationStatus, message); +} + +function confirmDiscardUnsavedConfiguration(context) { + if (!configurationDirty && !routingDirty) return true; + return window.confirm(`Discard unsaved ${configurationDirty && routingDirty ? 'YAML and routing' : configurationDirty ? 'YAML' : 'routing'} changes before ${context}?`); +} + +function syncFileSelection() { + const selected = nodeIndex.get(selectedServerId); + const proxyFile = [...configurationFile.options].find(option => option.value === 'bungeeconfig.yml'); + if (proxyFile) proxyFile.disabled = !(selected?.online && isProxy(selected) + && selected.acceptedCapabilities.includes('config.proxy-files.v1')); + const proxySelected = Boolean(selected?.online && isProxy(selected) + && selected.acceptedCapabilities.includes('config.proxy-files.v1')); + const expected = proxySelected ? 'bungeeconfig.yml' : 'Config.yml'; + if ((proxySelected && configurationFile.value !== 'bungeeconfig.yml') + || (!proxySelected && configurationFile.value === 'bungeeconfig.yml')) { + configurationFile.value = expected; + configurationFileSelection = expected; + resetFileEditorForSelection('Read the selected file before previewing changes.'); + } +} + +async function autoLoadTab(tab) { + if (!authenticated || autoLoadInFlight.has(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); } + return; + } + if (tab === 'network' && !proxyMethodWorkflowInFlight) { + autoLoadInFlight.add(tab); + try { + await Promise.all([proxyMethodCurrentValue ? Promise.resolve() : loadProxyMethod(true), + routingDirty || approvedPreview ? Promise.resolve() : loadProxyRouting(true)]); + } finally { autoLoadInFlight.delete(tab); } + return; + } + if (tab === 'quick-setup' && quickPresetReadable() && !loadedQuickSetup + && !approvedQuickPreview && !configurationOperationsInFlight) { + autoLoadInFlight.add(tab); + try { await loadQuickSetupValues(true); } finally { autoLoadInFlight.delete(tab); } + return; + } + if (tab === 'data' && inspectionCapableNode() && !inspectionInFlight && !lastOverview) { + autoLoadInFlight.add(tab); + try { await refreshOverview(dataOverview); } finally { autoLoadInFlight.delete(tab); } + } } function chooseDefaultServer(items) { @@ -895,8 +1232,10 @@ function renderSelectedServer() { text(configurationContext, 'Choose a backend from the server picker to work with its VotingPlugin configuration.'); text(commentPreservationState, 'Comment support unknown'); commentPreservationState.className = 'pill warning'; + syncFileSelection(); return; } + syncFileSelection(); text(selectedServerName, selected.displayName); text(selectedServerState, selected.online ? 'Control connected' : 'Control disconnected'); selectedServerState.className = `pill ${selected.online ? 'online' : 'offline'}`; @@ -907,7 +1246,7 @@ function renderSelectedServer() { `${roleLabel(selected)} · ${platformLabel(selected.platform)} · VotingPlugin ${selected.pluginVersion}.${relationshipText}`); text(configurationContext, `${selected.displayName} (${selected.nodeId}) · ${roleLabel(selected)} · ${selected.online ? 'Control connected' : 'Control disconnected'}`); - const fileTargets = targets('config.files.v1'); + const fileTargets = fileTargetsForSelection(); const preservesComments = fileTargets.length > 0 && fileTargets.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.file-comments.v1')); text(commentPreservationState, preservesComments ? 'Comments preserved for every target' : 'Comments not guaranteed for every target'); @@ -1236,13 +1575,13 @@ function updateExtendedButtons() { const quickReady = authenticated && Boolean(node?.online && isBackend(node) && node.acceptedCapabilities.includes('config.quick-setup.v1')) && backendTargets.length > 0 && configurationOperationsInFlight === 0; - const fileTargets = targets('config.files.v1'); + const fileTargets = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); const driftReady = authenticated && fileTargets.length >= 2 && configurationOperationsInFlight === 0; runNetworkDoctor.disabled = !inspectionReady; downloadNetworkDiagnostics.disabled = !lastDiagnostics; refreshSetupChecklist.disabled = !inspectionReady; refreshDataOverview.disabled = !inspectionReady; - lookupPlayer.disabled = !inspectionReady; + lookupPlayer.disabled = !inspectionReady || configurationOperationsInFlight > 0; loadSiteHealth.disabled = !inspectionReady; loadVoteLogSummary.disabled = !inspectionReady; searchVoteLog.disabled = !inspectionReady; @@ -1293,11 +1632,9 @@ function renderNodeViews() { function resetServerConfigurationForms(status) { configurationForm.reset(); - configurationContent.value = ''; - configurationContentPresent = false; + routingDirty = false; + resetFileEditorForSelection(status); text(operationStatus, status); - text(fileOperationStatus, status); - updateEditorPosition(); clearApprovals(); } @@ -1352,6 +1689,10 @@ function resetServerContextValues(reason) { function selectPrimaryServer(nodeId) { if (nodeId && !nodeIndex.has(nodeId)) return; + if (nodeId !== selectedServerId && !confirmDiscardUnsavedConfiguration('switching servers')) { + serverPicker.value = selectedServerId; + return; + } selectedServerId = nodeId; serverPicker.value = nodeId; selectedNodes.clear(); @@ -1365,8 +1706,9 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | const primaryCapabilities = nodeCapabilities.get(selectedServerId) || []; const routingReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && targets('config.proxy-routing.v1').length > 0 && !busy; - const fileReady = authenticated && primaryCapabilities.includes('config.files.v1') && - targets('config.files.v1').length > 0 && !busy; + const fileCapability = selectedFileCapability(); + const fileReady = authenticated && primaryCapabilities.includes(fileCapability) && + fileTargetsForSelection().length > 0 && !busy; const syncSelected = quickPreset.value === 'sync-vote-sites'; const quickReady = authenticated && !busy && (syncSelected ? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0) @@ -1561,7 +1903,11 @@ function discardAuthenticationState(reason) { nodePlugins.clear(); configurationForm.reset(); fileConfigurationForm.reset(); + configurationFileSelection = configurationFile.value; configurationContentPresent = false; + configurationDirty = false; + routingDirty = false; + autoLoadInFlight.clear(); quickSetupForm.reset(); resetDedicatedSetupValues(); rewardSimulationForm.reset(); @@ -1675,17 +2021,29 @@ function operationSummary(operation) { return lines.join('\n'); } -async function waitForOperation(operation, statusElement = operationStatus) { - text(statusElement, operationSummary(operation)); +function operationContext() { + return {authenticationGeneration, inputGeneration, selectedServerId, + selectedSessionId: nodeIndex.get(selectedServerId)?.sessionId}; +} + +function operationContextCurrent(context) { + return context.authenticationGeneration === authenticationGeneration && context.inputGeneration === inputGeneration + && context.selectedServerId === selectedServerId + && context.selectedSessionId === nodeIndex.get(context.selectedServerId)?.sessionId; +} + +async function waitForOperation(operation, statusElement = operationStatus, context = operationContext()) { + if (operationContextCurrent(context)) text(statusElement, operationSummary(operation)); rememberOperation(operation); while (operation.state === 'RUNNING') { await new Promise(resolve => window.setTimeout(resolve, 1500)); operation = await authorized(`/api/v1/operations/${operation.operationId}`); - text(statusElement, operationSummary(operation)); + if (operationContextCurrent(context)) text(statusElement, operationSummary(operation)); rememberOperation(operation); } rememberVoteLoggingRestart(operation); - if (operation.type === 'APPLY' && Object.values(operation.results || {}).some(result => result?.success)) { + if (operationContextCurrent(context) && operation.type === 'APPLY' + && Object.values(operation.results || {}).some(result => result?.success)) { fileReadCache.clear(); lastFileReadOperation = null; lastOverview = null; @@ -1703,13 +2061,14 @@ async function startConfigurationOperation(path, body, statusElement = operation dedicatedSetupApprovals.clear(); inputGeneration++; } + const context = operationContext(); configurationOperationsInFlight++; updateConfigurationButtons(); updateExtendedButtons(); try { return await waitForOperation(await authorized(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) - }), statusElement); + }), statusElement, context); } finally { configurationOperationsInFlight--; updateConfigurationButtons(); @@ -1842,7 +2201,8 @@ async function loadNodes() { nodePlugins = new Map(registry.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) ? node.detectedPlugins : []])); const selectedCapabilitiesChanged = [...selectedNodes].some(node => - ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1', 'data.inspect.v1'].some(capability => + ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1', + 'data.inspect.v1'].some(capability => Boolean(previousCapabilities.get(node)?.includes(capability)) !== Boolean(nodeCapabilities.get(node)?.includes(capability)))); if (selectedCapabilitiesChanged) { @@ -1858,7 +2218,7 @@ async function loadNodes() { const invalidRoutingApproval = approvedPreview && !approvedPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.proxy-routing.v1')); const invalidFileApproval = approvedFilePreview && !approvedFilePreview.nodeIds.every(node => - nodeCapabilities.get(node)?.includes('config.files.v1')); + nodeCapabilities.get(node)?.includes(selectedFileCapability())); const invalidQuickApproval = approvedQuickPreview && approvedQuickPreview.workflow !== 'sync-vote-sites' && !approvedQuickPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.quick-setup.v1')); @@ -1875,7 +2235,7 @@ async function loadNodes() { inputGeneration++; text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); } - const visibleIds = new Set(registry.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.'))) + const visibleIds = new Set(registry.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.') || value.startsWith('data.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); renderServerPicker(); @@ -1910,6 +2270,7 @@ async function loadNodes() { text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`); previousPage.disabled = pageOffset === 0; nextPage.disabled = pageOffset + visibleNodeItems.length >= registry.items.length; + void autoLoadTab(tabFromHash()); } catch (error) { visibleNodeItems = []; allNodeItems = []; @@ -2049,23 +2410,34 @@ enrollmentForm.addEventListener('submit', async event => { }); refreshEnrollments.addEventListener('click', loadEnrollments); -readConfiguration.addEventListener('click', async () => { +async function loadProxyRouting(automatic = false) { + if (!automatic && routingDirty && !window.confirm('Discard unsaved routing changes and load current values?')) return; approvedPreview = null; const readAuthenticationGeneration = authenticationGeneration; const readInputGeneration = inputGeneration; + const readNodeId = selectedServerId; + const readSessionId = nodeIndex.get(readNodeId)?.sessionId; + if (!nodeCapabilities.get(readNodeId)?.includes('config.proxy-routing.v1')) return; try { - const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: [selectedServerId]}); - const retained = Object.values(operation.results).find(result => result.success && result.configuration); + const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: [readNodeId]}); + const retained = operation.results?.[readNodeId]; if (retained && authenticated && readAuthenticationGeneration === authenticationGeneration - && readInputGeneration === inputGeneration) { + && readInputGeneration === inputGeneration && readNodeId === selectedServerId + && readSessionId === nodeIndex.get(readNodeId)?.sessionId && retained.sessionId === readSessionId + && retained.success && retained.configuration) { sendAll.checked = retained.configuration.sendVotesToAllServers; blockedServers.value = retained.configuration.blockedServers.join('\n'); approvedPreview = null; + routingDirty = false; inputGeneration++; updateConfigurationButtons(); } - } catch (error) { text(operationStatus, error.message); } -}); + } catch (error) { + if (!automatic) text(operationStatus, error.message); + } +} + +readConfiguration.addEventListener('click', () => { void loadProxyRouting(false); }); previewConfiguration.addEventListener('click', async () => { approvedPreview = null; @@ -2091,29 +2463,40 @@ applyConfiguration.addEventListener('click', async () => { approvedPreview = null; inputGeneration++; try { - await startConfigurationOperation('/api/v1/configuration/apply', { + const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }); + if (operation.state === 'SUCCEEDED') routingDirty = false; } catch (error) { text(operationStatus, error.message); } }); [sendAll, blockedServers].forEach(field => field.addEventListener('input', () => { + routingDirty = true; if (approvedPreview) text(operationStatus, 'The proposal changed. Preview it again before apply.'); approvedPreview = null; inputGeneration++; updateConfigurationButtons(); })); -readFileConfiguration.addEventListener('click', async () => { +async function loadFileConfiguration(automatic = false) { + if (!automatic && configurationDirty + && !window.confirm(`Discard unsaved ${configurationFile.value} changes and load the current file?`)) return; approvedFilePreview = null; const readAuthenticationGeneration = authenticationGeneration; const readInputGeneration = inputGeneration; const selectedFile = configurationFile.value; const selectedNode = nodeIndex.get(selectedServerId); + const selectedReadNodeId = selectedNode?.online && selectedNode.acceptedCapabilities.includes(selectedFileCapability(selectedFile)) + && (selectedFile === 'bungeeconfig.yml' ? isProxy(selectedNode) : isBackend(selectedNode)) ? selectedServerId : ''; + if (!selectedReadNodeId) { + if (!automatic) text(fileOperationStatus, 'Choose a connected node that supports this configuration file.'); + return; + } const cacheKey = `${selectedServerId}|${selectedNode?.sessionId || ''}|${selectedFile}`; const cached = cachedFile(cacheKey); if (cached) { configurationContent.value = cached.content; configurationContentPresent = true; + configurationDirty = false; lastFileReadOperation = {operationId: cached.operationId}; updateEditorPosition(); text(fileOperationStatus, `Cached read · ${selectedServerId} · ${selectedFile}\nLoaded instantly; cache expires after 30 seconds. Preview still checks the live revision.`); @@ -2124,15 +2507,17 @@ readFileConfiguration.addEventListener('click', async () => { } try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { - nodeIds: [selectedServerId], + nodeIds: [selectedReadNodeId], configuration: {domain: 'file', fileName: selectedFile} }, fileOperationStatus); - const contentResult = Object.values(operation.results).find(result => - result.success && result.configuration?.content != null); + const contentResult = operation.results?.[selectedReadNodeId]; if (contentResult && authenticated && readAuthenticationGeneration === authenticationGeneration - && readInputGeneration === inputGeneration && selectedFile === configurationFile.value) { + && readInputGeneration === inputGeneration && selectedFile === configurationFile.value + && selectedReadNodeId === selectedServerId && selectedNode.sessionId === nodeIndex.get(selectedReadNodeId)?.sessionId + && contentResult.success && typeof contentResult.configuration?.content === 'string') { configurationContent.value = contentResult.configuration.content; configurationContentPresent = true; + configurationDirty = false; lastFileReadOperation = {operationId: operation.operationId}; cacheFile(cacheKey, contentResult.configuration.content, operation.operationId); updateEditorPosition(); @@ -2142,29 +2527,44 @@ readFileConfiguration.addEventListener('click', async () => { updateExtendedButtons(); } } catch (error) { text(fileOperationStatus, error.message); } -}); +} + +readFileConfiguration.addEventListener('click', () => { void loadFileConfiguration(false); }); previewFileConfiguration.addEventListener('click', async () => { approvedFilePreview = null; const previewGeneration = inputGeneration; + const selectedFile = configurationFile.value; + const previewTargets = fileTargetsForSelection(selectedFile); + const previewSessions = new Map(previewTargets.map(nodeId => [nodeId, nodeIndex.get(nodeId)?.sessionId])); try { const operation = await startConfigurationOperation('/api/v1/configuration/preview', { - nodeIds: targets('config.files.v1'), - configuration: {domain: 'file', fileName: configurationFile.value, content: configurationContent.value} + nodeIds: previewTargets, + configuration: {domain: 'file', fileName: selectedFile, content: configurationContent.value} }, fileOperationStatus); - text(fileOperationStatus, operationSummary(operation)); - if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { + const targetsCurrent = selectedFile === configurationFile.value && previewTargets.length > 0 + && previewTargets.every(nodeId => previewSessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId) + && previewTargets.every(nodeId => fileTargetsForSelection(selectedFile).includes(nodeId)); + if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration && targetsCurrent) { + text(fileOperationStatus, operationSummary(operation)); approvedFilePreview = {operationId: operation.operationId, approvalToken: operation.approvalToken, - nodeIds: targets('config.files.v1')}; + nodeIds: previewTargets, fileName: selectedFile, sessions: previewSessions}; updateConfigurationButtons(); - } else if (previewGeneration !== inputGeneration) { + } else if (previewGeneration !== inputGeneration || !targetsCurrent) { text(fileOperationStatus, 'The targets or file changed while previewing. Preview again before apply.'); + } else { + text(fileOperationStatus, operationSummary(operation)); } } catch (error) { text(fileOperationStatus, error.message); } }); applyFileConfiguration.addEventListener('click', async () => { - if (!approvedFilePreview || !window.confirm(`Apply this exact ${configurationFile.value} preview to every selected Bukkit node?`)) return; + const currentTargets = fileTargetsForSelection(configurationFile.value); + if (!approvedFilePreview || approvedFilePreview.fileName !== configurationFile.value + || !approvedFilePreview.nodeIds.every(nodeId => approvedFilePreview.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId) + || currentTargets.length !== approvedFilePreview.nodeIds.length + || !approvedFilePreview.nodeIds.every(nodeId => currentTargets.includes(nodeId)) + || !window.confirm(`Apply this exact ${configurationFile.value} preview to ${fileTargetDescription()}?`)) return; const approval = approvedFilePreview; approvedFilePreview = null; inputGeneration++; @@ -2176,6 +2576,7 @@ applyFileConfiguration.addEventListener('click', async () => { if (operation.state === 'SUCCEEDED') { fileReadCache.clear(); lastFileReadOperation = null; + configurationDirty = false; updateExtendedButtons(); } } catch (error) { text(fileOperationStatus, error.message); } @@ -2245,7 +2646,7 @@ function populateQuickState(options) { } } -readQuickSetup.addEventListener('click', async () => { +async function loadQuickSetupValues(automatic = false) { if (!quickPresetReadable()) return; approvedQuickPreview = null; loadedQuickSetup = null; @@ -2286,8 +2687,12 @@ readQuickSetup.addEventListener('click', async () => { ? ` ${result.configuration.options.rewardCommandCount} existing reward command(s) will be preserved.` : ''; text(quickOperationStatus, `Current values loaded from ${Object.keys(operation.results).find(id => operation.results[id] === result)}.${suffix}`); updateConfigurationButtons(); - } catch (error) { text(quickOperationStatus, error.message); } -}); + } catch (error) { + if (!automatic) text(quickOperationStatus, error.message); + } +} + +readQuickSetup.addEventListener('click', () => { void loadQuickSetupValues(false); }); previewQuickSetup.addEventListener('click', async () => { approvedQuickPreview = null; @@ -2380,12 +2785,18 @@ transportTestBackend.addEventListener('change', () => { runTransportTest.addEventListener('click', async () => { const proxyId = transportTestProxyId; const server = transportTestBackendId; + const requestAuthenticationGeneration = authenticationGeneration; + const proxySessionId = nodeIndex.get(proxyId)?.sessionId; + const backendSessionId = nodeIndex.get(server)?.sessionId; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [proxyId], configuration: {domain: 'quick-setup', preset: 'communication-test', options: {server}} }, transportTestStatus); - if (proxyId !== transportTestProxyId || server !== transportTestBackendId) { + if (requestAuthenticationGeneration !== authenticationGeneration || proxyId !== transportTestProxyId + || server !== transportTestBackendId || proxySessionId !== nodeIndex.get(proxyId)?.sessionId + || backendSessionId !== nodeIndex.get(server)?.sessionId + || operation.results?.[proxyId]?.sessionId !== proxySessionId) { text(transportTestStatus, 'The proxy or backend changed while testing. Run the test again.'); return; } @@ -2393,9 +2804,10 @@ runTransportTest.addEventListener('click', async () => { } catch (error) { text(transportTestStatus, error.message); } }); -readProxyMethod.addEventListener('click', async () => { +async function loadProxyMethod(automatic = false) { const proxyId = proxyMethodProxyId; const sessionId = proxyMethodNetwork().proxy?.sessionId; + const requestAuthenticationGeneration = authenticationGeneration; if (!proxyId) return; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { @@ -2405,14 +2817,19 @@ readProxyMethod.addEventListener('click', async () => { const result = operation.results[proxyId]; const method = result?.success ? result.configuration?.options?.method : ''; if (!method) throw new Error('The proxy did not return its active communication method.'); - if (proxyId !== proxyMethodProxyId || sessionId !== proxyMethodNetwork().proxy?.sessionId) return; + if (requestAuthenticationGeneration !== authenticationGeneration || proxyId !== proxyMethodProxyId + || sessionId !== proxyMethodNetwork().proxy?.sessionId || result?.sessionId !== sessionId) return; proxyMethodCurrentFor = proxyId; proxyMethodCurrentSessionId = sessionId; proxyMethodCurrentValue = method; renderProxyMethod(); text(proxyMethodStatus, `Active method on ${proxyId}: ${method}`); - } catch (error) { text(proxyMethodStatus, error.message); } -}); + } catch (error) { + if (!automatic) text(proxyMethodStatus, error.message); + } +} + +readProxyMethod.addEventListener('click', () => { void loadProxyMethod(false); }); proxyMethodProxy.addEventListener('change', () => { proxyMethodProxyId = proxyMethodProxy.value; @@ -2441,7 +2858,7 @@ proxyMethodButtons.forEach(button => button.addEventListener('click', async () = }, proxyMethodStatus); if (preview.state !== 'SUCCEEDED' || !preview.approvalToken) return; if (!window.confirm(`Switch ${network.nodeIds.length} VotingPlugin nodes to ${method}? ` + - 'The proxy runtime will restart after Control records the result.')) return; + 'Backends reload their communication handler; the proxy replaces its runtime after Control records the result.')) return; const refreshedRegistry = await loadAllNodes(); const refreshedNetwork = proxyMethodNetworkFor(refreshedRegistry.items, refreshedRegistry.truncatedNodeIds, proxyMethodProxyId); @@ -2462,7 +2879,7 @@ proxyMethodButtons.forEach(button => button.addEventListener('click', async () = renderProxyMethod(); } const nextStep = applied.state === 'SUCCEEDED' - ? 'Reconnect the proxy if needed, then run the communication test.' + ? 'Wait for the proxy to reconnect, then run the communication test to confirm the active transport.' : 'No network-wide method change was committed. Fix the failed nodes, refresh the active method, and preview again.'; text(proxyMethodStatus, `${operationSummary(applied)}\n${nextStep}`); } catch (error) { @@ -2625,18 +3042,23 @@ runNetworkDoctor.addEventListener('click', async () => { const diagnostics = await runInspection('diagnostics', {}, networkDoctorResults); lastOverview = diagnostics.result; 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 checks = { controlConnected: Boolean(node?.online), configurationHealthy: diagnostics.result.configurationHealthy, votifierDetected: diagnostics.result.votifierDetected, voteSitesConfigured: Number(diagnostics.result.configuredVoteSites) > 0, processRewards: diagnostics.result.processRewards, - voteLoggingEnabled: diagnostics.result.voteLoggingEnabled, + voteLogging: voteLog, topologyReported: isBackend(node) ? proxyReportsFor(node.nodeId).length > 0 || !diagnostics.result.proxyMode : true }; lastDiagnostics = { schemaVersion: 1, generatedAt: new Date().toISOString(), selectedNodeId: selectedServerId, - checks, node: diagnostics.result, + checks, voteLog, node: diagnostics.result, control: {application: 'VotingPlugin Control', registeredNodes: allNodeItems.length, nodes: allNodeItems.slice(0, 100).map(item => ({nodeId: item.nodeId, displayName: item.displayName, role: roleLabel(item), online: item.online, pluginVersion: item.pluginVersion}))} @@ -2652,19 +3074,26 @@ downloadNetworkDiagnostics.addEventListener('click', () => { }); runDriftCheck.addEventListener('click', async () => { - const nodeIds = targets('config.files.v1'); + const nodeIds = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); const selectedFile = driftFile.value; const requestAuthenticationGeneration = authenticationGeneration; + const requestInputGeneration = inputGeneration; + const requestSelectedNodeId = selectedServerId; + const requestSelectedSessionId = nodeIndex.get(requestSelectedNodeId)?.sessionId; const requestSessions = new Map(nodeIds.map(nodeId => [nodeId, nodeIndex.get(nodeId)?.sessionId])); try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds, configuration: {domain: 'file', fileName: selectedFile} }, driftResults); - if (requestAuthenticationGeneration !== authenticationGeneration) { - throw new Error('Authentication changed while the drift check ran. Run it again.'); - } - if (nodeIds.some(nodeId => requestSessions.get(nodeId) !== operation.results?.[nodeId]?.sessionId)) { - throw new Error('A selected server reconnected while the drift check ran. Run it again.'); + const currentTargets = targets('config.files.v1').filter(nodeId => isBackend(nodeIndex.get(nodeId))); + const targetsStillCurrent = selectedFile === driftFile.value && nodeIds.length >= 2 + && nodeIds.length === currentTargets.length && nodeIds.every(nodeId => currentTargets.includes(nodeId)) + && nodeIds.every(nodeId => requestSessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId + && requestSessions.get(nodeId) === operation.results?.[nodeId]?.sessionId); + if (requestAuthenticationGeneration !== authenticationGeneration || requestInputGeneration !== inputGeneration + || requestSelectedNodeId !== selectedServerId || requestSelectedSessionId !== nodeIndex.get(requestSelectedNodeId)?.sessionId + || !targetsStillCurrent) { + return; } const rows = nodeIds.map(nodeId => { const result = operation.results[nodeId]; @@ -2726,6 +3155,7 @@ async function loadSnapshots() { restore.type = 'button'; restore.className = 'secondary compact'; restore.addEventListener('click', async () => { + if (configurationDirty && !window.confirm('Discard unsaved YAML changes and load this snapshot?')) return; restore.disabled = true; const restoreServerId = selectedServerId; const restoreGeneration = inputGeneration; @@ -2733,15 +3163,20 @@ async function loadSnapshots() { const full = await authorized(`/api/v1/snapshots/${snapshot.snapshotId}`); if (restoreServerId !== selectedServerId || restoreGeneration !== inputGeneration) { throw new Error('The selected server changed while loading the snapshot. Load it again.'); - } + } const document = full.documents.find(value => value.nodeId === selectedServerId) || full.documents[0]; if (!document) throw new Error('This snapshot has no restorable document.'); - if (!nodeCapabilities.get(selectedServerId)?.includes('config.files.v1')) { - throw new Error('Choose a connected file-capable Bukkit node before restoring.'); + const restoreNode = nodeIndex.get(selectedServerId); + const proxyFile = document.fileName === 'bungeeconfig.yml'; + if (!restoreNode?.online || !nodeCapabilities.get(selectedServerId)?.includes(selectedFileCapability(document.fileName)) + || (proxyFile ? !isProxy(restoreNode) : !isBackend(restoreNode))) { + throw new Error('Choose a connected node that supports this snapshot file before restoring.'); } configurationFile.value = document.fileName; + configurationFileSelection = document.fileName; configurationContent.value = document.content; configurationContentPresent = true; + configurationDirty = false; lastFileReadOperation = null; updateEditorPosition(); approvedFilePreview = null; @@ -2778,8 +3213,7 @@ refreshSnapshots.addEventListener('click', loadSnapshots); playerLookupForm.addEventListener('submit', async event => { event.preventDefault(); const value = playerLookup.value.trim(); - const filter = /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value) ? {uuid: value} : {name: value}; - try { renderJsonResult(playerResult, (await runInspection('player', filter, playerResult)).result); } + try { await loadPlayerData(value); } catch (error) { text(playerResult, error.message); } }); @@ -2810,8 +3244,7 @@ voteLogForm.addEventListener('submit', async event => { voteTraceForm.addEventListener('submit', async event => { event.preventDefault(); - try { renderJsonResult(voteTraceResult, (await runInspection('vote-trace', - {voteId: voteTraceId.value.trim(), days: voteLogDays.value, limit: '100'}, voteTraceResult)).result); } + try { await traceVoteAcrossNodes(); } catch (error) { text(voteTraceResult, error.message); } }); @@ -2984,6 +3417,7 @@ quickName.addEventListener('input', () => { }); configurationContent.addEventListener('input', () => { configurationContentPresent = true; + configurationDirty = true; clearApprovals(); updateEditorPosition(); }); @@ -2991,13 +3425,17 @@ configurationContent.addEventListener('click', updateEditorPosition); configurationContent.addEventListener('keyup', updateEditorPosition); configurationContent.addEventListener('keydown', handleEditorKeydown); configurationFile.addEventListener('input', () => { - configurationContent.value = ''; - configurationContentPresent = false; - lastFileReadOperation = null; - updateEditorPosition(); - text(fileOperationStatus, 'Read the selected file before previewing changes.'); + const requestedFile = configurationFile.value; + if (requestedFile !== configurationFileSelection && configurationDirty + && !window.confirm('Discard unsaved YAML changes and switch files?')) { + configurationFile.value = configurationFileSelection; + return; + } + configurationFileSelection = requestedFile; + resetFileEditorForSelection('Read the selected file before previewing changes.'); clearApprovals(); updateExtendedButtons(); + void autoLoadTab('configurations'); }); quickPreset.addEventListener('input', () => { loadedQuickSetup = null; @@ -3025,6 +3463,11 @@ document.querySelectorAll('[data-open-config-view]').forEach(button => button.ad } })); window.addEventListener('hashchange', () => setActiveTab(tabFromHash())); +window.addEventListener('beforeunload', event => { + if (!configurationDirty && !routingDirty) return; + event.preventDefault(); + event.returnValue = ''; +}); refresh.addEventListener('click', loadNodes); previousPage.addEventListener('click', () => { pageOffset = Math.max(0, pageOffset - PAGE_SIZE); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 990053c..556bcba 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -249,7 +249,7 @@

Configurations

Full VotingPlugin configuration

-

Edit any managed Bukkit YAML file. Secret values use __VOTINGPLUGIN_CONTROL_REDACTED__; leave the marker unchanged to preserve the stored value.

+

Edit a managed Bukkit YAML file, or the selected proxy's bungeeconfig.yml. Secret values use __VOTINGPLUGIN_CONTROL_REDACTED__; leave the marker unchanged to preserve the stored value.

Comment support unknown
@@ -263,6 +263,7 @@

Full VotingPlugin configuration

+ YAML · 512 KiB maximum @@ -485,14 +486,14 @@

Setup

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 1dfb431..fa07aee 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -1054,6 +1054,63 @@ class ConfigurationOperationsTest { ManagedConfiguration.proxy(new ProxyRoutingConfiguration(false, List.of())))).code()); } + @Test void bungeeConfigUsesTheOptionalProxyFileCapabilityAndRejectsLegacyTargets() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID capableProxySession = UUID.randomUUID(); + UUID legacyProxySession = UUID.randomUUID(); + UUID misleadingBackendSession = UUID.randomUUID(); + registry.register(new NodeRegistration("velocity-a", capableProxySession, "Velocity A", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.PROXY_FILE_CAPABILITY), Set.of())); + registry.register(new NodeRegistration("bungee-legacy", legacyProxySession, "Legacy Bungee", "BUNGEECORD", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); + registry.register(new NodeRegistration("misleading-backend", misleadingBackendSession, "Backend", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.PROXY_FILE_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + + ManagedConfiguration bungeeConfig = ManagedConfiguration.file("bungeeconfig.yml", null); + assertEquals(ConfigurationOperations.PROXY_FILE_CAPABILITY, bungeeConfig.capability()); + ConfigurationOperations.OperationView read = operations.createRead(List.of("velocity-a"), bungeeConfig); + assertEquals("bungeeconfig.yml", operations.claim("velocity-a", capableProxySession).configuration().fileName()); + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> operations.createRead(List.of("bungee-legacy"), bungeeConfig)).code()); + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> operations.createRead(List.of("velocity-a", "bungee-legacy"), bungeeConfig)).code()); + ValidationException wrongRole = assertThrows(ValidationException.class, + () -> operations.createRead(List.of("misleading-backend"), bungeeConfig)); + assertEquals("INVALID_TARGET", wrongRole.code()); + assertEquals(List.of("misleading-backend"), wrongRole.details()); + + ValidationException backendFileOnProxy = assertThrows(ValidationException.class, + () -> operations.createRead(List.of("bungee-legacy"), ManagedConfiguration.file("Config.yml", null))); + assertEquals("INVALID_TARGET", backendFileOnProxy.code()); + assertEquals(List.of("bungee-legacy"), backendFileOnProxy.details()); + assertEquals(1, operations.list().size()); + assertEquals(read.operationId(), operations.get(read.operationId()).operationId()); + } + + @Test void claimCancelsProxyFileTaskWhenTheNodeChangesRoleWithinItsSession() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + Set capabilities = Set.of(ConfigurationOperations.PROXY_FILE_CAPABILITY); + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + capabilities, Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + UUID operation = operations.createRead(List.of("proxy-a"), + ManagedConfiguration.file("bungeeconfig.yml", null)).operationId(); + + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "BUKKIT", "test", 1, + capabilities, Set.of())); + + assertEquals(null, operations.claim("proxy-a", session)); + ConfigurationOperations.OperationView view = operations.get(operation); + assertEquals("COMPLETED_WITH_ERRORS", view.state()); + assertEquals("TARGET_CHANGED", view.results().get("proxy-a").code()); + } + @Test void claimCancelsTaskWhenCurrentSessionLostItsCapability() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); 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 ad5465c..216211f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -123,11 +123,18 @@ class ControlHttpServerTest { assertTrue(script.body().contains("loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("sessionId !== nodeIndex.get(nodeId)?.sessionId")); + assertTrue(script.body().contains("retained.sessionId === readSessionId")); + assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); + assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); + assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); + assertTrue(script.body().contains("Discard unsaved ${configurationFile.value} changes and load the current file?")); + assertTrue(script.body().contains("window.addEventListener('beforeunload'")); assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset, selector}")); assertTrue(script.body().contains("configurationOperationsInFlight")); - assertTrue(script.body().contains("approvedPreview = null;\n inputGeneration++;")); + assertTrue(script.body().contains("if (selectedCapabilitiesChanged) {\n approvedPreview = null;")); assertTrue(script.body().contains("approvedPreview.nodeIds.every")); assertTrue(script.body().contains("selectedCapabilitiesChanged")); + assertTrue(script.body().contains("proxyFile ? !isProxy(restoreNode) : !isBackend(restoreNode)")); assertTrue(script.body().contains("discardAuthenticationState")); assertTrue(script.body().contains("text(operationStatus, '');")); assertTrue(script.body().contains("text(fileOperationStatus, '');")); @@ -281,7 +288,8 @@ class ControlHttpServerTest { @Test void inspectionRetryAndSnapshotRoutesAreEndToEnd() throws Exception { String capableRegistration = registration().replace("\"presence.snapshot\"]", - "\"presence.snapshot\",\"data.inspect.v1\",\"config.files.v1\"]"); + "\"presence.snapshot\",\"data.inspect.v1\",\"config.files.v1\"]") + .replace("\"platform\":\"VELOCITY\"", "\"platform\":\"BUKKIT\""); assertEquals(201, send("POST", "/api/v1/nodes/register", capableRegistration, nodeToken).statusCode()); HttpResponse inspectionQueued = send("POST", "/api/v1/inspections", From 5519399db8d72ee134dacc49108af21a1995378f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:28:19 -0600 Subject: [PATCH 2/6] Harden configuration task state changes --- .../domain/ConfigurationOperations.java | 22 ++++++--- src/main/resources/web/app.js | 33 +++++++++---- .../domain/ConfigurationOperationsTest.java | 46 +++++++++++++++++++ .../control/http/ControlHttpServerTest.java | 6 ++- 4 files changed, 89 insertions(+), 18 deletions(-) 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 41d4597..9b4cf0f 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -239,11 +239,7 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { if (cancelChangedFileRole(operation, node)) continue; if (cancelChangedProxyMethodRole(operation, node)) continue; if (deferProxyMethodApply(operation, node)) continue; - if (!node.online() || !node.acceptedCapabilities().contains(operation.configuration.capability())) { - automaticCancellation(operation, nodeId, sessionId(node), "CAPABILITY_LOST", - "Node no longer accepts this configuration capability", "CAPABILITY_LOST"); - continue; - } + if (cancelLostCapability(operation, node)) continue; UUID previousAttempt = operation.attemptIds.get(nodeId); UUID previousClaimSession = operation.claimSessions.get(nodeId); UUID attemptId = UUID.randomUUID(); @@ -360,6 +356,13 @@ private boolean cancelChangedFileRole(StoredOperation operation, NodeStatus node return true; } + private boolean cancelLostCapability(StoredOperation operation, NodeStatus node) { + if (node.online() && node.acceptedCapabilities().contains(operation.configuration.capability())) return false; + automaticCancellation(operation, node.nodeId(), sessionId(node), "CAPABILITY_LOST", + "Node no longer accepts this configuration capability", "CAPABILITY_LOST"); + return true; + } + /** * Commits an automatic task completion only after the redacted journal is durable. Both the * operation maps and retention counters are restored if either durable step fails, leaving the @@ -489,14 +492,15 @@ private static void validateApprovedTargets(StoredOperation preview, ValidatedTa public synchronized OperationView complete(UUID operationId, String nodeId, ConfigurationTaskResult result) { validateResult(result); return registry.withSession(nodeId, result.sessionId(), - ignored -> completeCurrentSession(operationId, nodeId, result)); + node -> completeCurrentSession(operationId, nodeId, result, node)); } private static UUID sessionId(NodeStatus node) { return node.sessionId(); } - private OperationView completeCurrentSession(UUID operationId, String nodeId, ConfigurationTaskResult result) { + private OperationView completeCurrentSession(UUID operationId, String nodeId, ConfigurationTaskResult result, + NodeStatus node) { StoredOperation operation = operations.get(operationId); if (operation == null || !operation.states.containsKey(nodeId)) { throw new ValidationException("OPERATION_NOT_FOUND", "Operation task was not found", List.of()); @@ -516,6 +520,10 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co if (!Objects.equals(operation.claimSessions.get(nodeId), result.sessionId())) { throw new ValidationException("SESSION_MISMATCH", "Operation task belongs to another node session", List.of()); } + if (cancelChangedFileRole(operation, node) || cancelChangedProxyMethodRole(operation, node) + || cancelLostCapability(operation, node)) { + return view(operation); + } validateResultConfiguration(operation, result); String priorState = operation.states.get(nodeId); ConfigurationTaskResult priorResult = operation.results.get(nodeId); diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 99511ee..c4b4f30 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -645,7 +645,7 @@ async function traceVoteAcrossNodes() { const contextCurrent = () => requestAuthenticationGeneration === authenticationGeneration && requestInputGeneration === inputGeneration && requestSelectedNodeId === selectedServerId && requestSelectedSessionId === nodeIndex.get(requestSelectedNodeId)?.sessionId - && days === String(voteLogDays.value) + && voteId === voteTraceId.value.trim() && days === String(voteLogDays.value) && candidates.every(node => candidateSessions.get(node.nodeId) === nodeIndex.get(node.nodeId)?.sessionId); const events = new Map(); const sources = []; @@ -673,7 +673,12 @@ async function traceVoteAcrossNodes() { unavailable.push(`${node.displayName} (${node.nodeId}): ${error.message || 'inspection failed'}`); } } - if (!contextCurrent()) return; + if (!contextCurrent()) { + if (requestAuthenticationGeneration === authenticationGeneration) { + text(voteTraceResult, 'The selected server, node session, or trace window changed. Run the trace again.'); + } + return; + } if (Date.now() >= traceDeadline) unavailable.push('The remaining nodes were omitted after the 90-second trace budget expired'); const ordered = [...events.values()].map(item => ({...item, sources: [...new Set(item.sources)].sort()})).sort((left, right) => Number(left.event.voteTime || 0) - Number(right.event.voteTime || 0) @@ -1158,6 +1163,10 @@ function syncFileSelection() { const expected = proxySelected ? 'bungeeconfig.yml' : 'Config.yml'; if ((proxySelected && configurationFile.value !== 'bungeeconfig.yml') || (!proxySelected && configurationFile.value === 'bungeeconfig.yml')) { + if (!proxySelected && configurationFile.value === 'bungeeconfig.yml' && configurationDirty) { + text(fileOperationStatus, 'The selected proxy is no longer available for this file. Your unsaved draft is retained; reconnect the proxy or explicitly switch files to discard it.'); + return; + } configurationFile.value = expected; configurationFileSelection = expected; resetFileEditorForSelection('Read the selected file before previewing changes.'); @@ -1215,7 +1224,7 @@ function renderServerPicker() { if (!nodeIndex.has(previousValue)) { selectedServerId = chooseDefaultServer(ordered)?.nodeId || ''; if (previousValue) { - resetServerContextValues('The selected server is no longer available. Load current values from its replacement.'); + resetServerContextValues('The selected server is no longer available. Load current values from its replacement.', true); } } serverPicker.value = selectedServerId; @@ -1630,10 +1639,16 @@ function renderNodeViews() { updateExtendedButtons(); } -function resetServerConfigurationForms(status) { +function resetServerConfigurationForms(status, preserveDirtyFile = false) { configurationForm.reset(); routingDirty = false; - resetFileEditorForSelection(status); + if (preserveDirtyFile && configurationDirty) { + lastFileReadOperation = null; + approvedFilePreview = null; + text(fileOperationStatus, `${status} Your unsaved ${configurationFile.value} draft is retained; explicitly switch files or load the current file to discard it.`); + } else { + resetFileEditorForSelection(status); + } text(operationStatus, status); clearApprovals(); } @@ -1649,7 +1664,7 @@ function resetDedicatedSetupValues() { voteLoggingState.className = 'pill neutral'; } -function resetServerContextValues(reason) { +function resetServerContextValues(reason, preserveDirtyFile = false) { dedicatedSetupApprovals.clear(); pendingDetectedVoteSite = null; lastFileReadOperation = null; @@ -1679,7 +1694,7 @@ function resetServerContextValues(reason) { text(autoSitesStatus, reason); text(voteLoggingStatus, reason); loadedQuickSetup = null; - resetServerConfigurationForms(reason); + resetServerConfigurationForms(reason, preserveDirtyFile); const preset = quickPreset.value; quickSetupForm.reset(); quickPreset.value = preset; @@ -2194,7 +2209,7 @@ async function loadNodes() { const selectedSessionChanged = primarySessionChanged || [...selectedNodes].some(node => previousNodeIndex.get(node)?.sessionId && previousNodeIndex.get(node)?.sessionId !== nodeIndex.get(node)?.sessionId); if (selectedSessionChanged) { - resetServerContextValues('A selected server reconnected. Load current values before continuing.'); + resetServerContextValues('A selected server reconnected. Load current values before continuing.', true); } const previousCapabilities = nodeCapabilities; nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); @@ -2280,7 +2295,7 @@ async function loadNodes() { nodePlugins.clear(); selectedNodes.clear(); selectedServerId = ''; - resetServerContextValues('Network data is unavailable. Refresh and load current values before continuing.'); + resetServerContextValues('Network data is unavailable. Refresh and load current values before continuing.', true); renderServerPicker(); renderNodeViews(); updatePluginSuggestions(); 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 fa07aee..15bd06d 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -1111,6 +1111,52 @@ class ConfigurationOperationsTest { assertEquals("TARGET_CHANGED", view.results().get("proxy-a").code()); } + @Test void completionCancelsClaimedProxyFileTaskWhenTheNodeChangesRoleWithinItsSession() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + Set capabilities = Set.of(ConfigurationOperations.PROXY_FILE_CAPABILITY); + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + capabilities, Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration selector = ManagedConfiguration.file("bungeeconfig.yml", null); + ConfigurationOperations.OperationView read = operations.createRead(List.of("proxy-a"), selector); + ConfigurationTask task = operations.claim("proxy-a", session); + + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "BUKKIT", "test", 1, + capabilities, Set.of())); + read = operations.complete(read.operationId(), "proxy-a", new ConfigurationTaskResult(session, true, + "OK", "read", "a".repeat(64), ManagedConfiguration.file("bungeeconfig.yml", "Key: value\n"), + List.of(), false, false, task.attemptId())); + + assertEquals("COMPLETED_WITH_ERRORS", read.state()); + assertEquals("TARGET_CHANGED", read.results().get("proxy-a").code()); + assertEquals(false, read.results().get("proxy-a").success()); + } + + @Test void completionCancelsClaimedTaskWhenTheNodeLosesItsCapabilityWithinTheSession() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ConfigurationOperations.OperationView read = operations.createRead(List.of("proxy-a")); + ConfigurationTask task = operations.claim("proxy-a", session); + + registry.register(new NodeRegistration("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + Set.of(), Set.of())); + read = operations.complete(read.operationId(), "proxy-a", new ConfigurationTaskResult(session, true, + "OK", "read", "a".repeat(64), new ProxyRoutingConfiguration(false, List.of()), + List.of(), false, false, task.attemptId())); + + assertEquals("COMPLETED_WITH_ERRORS", read.state()); + assertEquals("CAPABILITY_LOST", read.results().get("proxy-a").code()); + assertFalse(read.results().get("proxy-a").success()); + } + @Test void claimCancelsTaskWhenCurrentSessionLostItsCapability() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); 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 216211f..ec44132 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -80,7 +80,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Control enrollment unavailable")); assertTrue(script.body().contains("Comments preserved for every target")); assertTrue(script.body().contains("Backend topology is truncated")); - assertTrue(script.body().contains("function resetServerConfigurationForms(status)")); + assertTrue(script.body().contains("function resetServerConfigurationForms(status, preserveDirtyFile = false)")); assertTrue(script.body().contains("Network data is unavailable. Refresh and load current values before continuing.")); assertTrue(script.body().contains("backendTopologyTruncated = false;")); assertTrue(script.body().contains("nextPage.addEventListener")); @@ -116,7 +116,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains( "configurationContent.value = document.content;\n configurationContentPresent = true;")); assertTrue(script.body().contains( - "resetServerContextValues('A selected server reconnected. Load current values before continuing.');")); + "resetServerContextValues('A selected server reconnected. Load current values before continuing.', true);")); assertTrue(script.body().contains( "configurationContent.addEventListener('input', () => {\n configurationContentPresent = true;")); assertTrue(script.body().contains("quickPresetNeedsRead() && !quickSetupValuesLoaded()")); @@ -126,6 +126,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("retained.sessionId === readSessionId")); assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); + assertTrue(script.body().contains("voteId === voteTraceId.value.trim()")); + assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); assertTrue(script.body().contains("Discard unsaved ${configurationFile.value} changes and load the current file?")); assertTrue(script.body().contains("window.addEventListener('beforeunload'")); From aadc70efcf4eb3322c650ff8e304556cad674e77 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:04 -0600 Subject: [PATCH 3/6] Preserve Control drafts across refreshes --- src/main/resources/web/app.js | 46 +++++++++++++------ .../control/http/ControlHttpServerTest.java | 8 +++- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index c4b4f30..410e31e 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -250,6 +250,7 @@ let lastFileReadOperation = null; let configurationContentPresent = false; let configurationDirty = false; let routingDirty = false; +let routingDraftNodeId = ''; let configurationFileSelection = configurationFile.value; let inspectionInFlight = false; let lastDiagnostics = null; @@ -954,6 +955,7 @@ function applyAuthenticatedSession(body) { configurationContentPresent = false; configurationDirty = false; routingDirty = false; + routingDraftNodeId = ''; configurationFileSelection = configurationFile.value; autoLoadInFlight.clear(); inputGeneration++; @@ -1578,6 +1580,7 @@ function updateSetupChecklist(overview = lastOverview) { function updateExtendedButtons() { const node = nodeIndex.get(selectedServerId); const inspectionReady = authenticated && Boolean(inspectionCapableNode()) && !inspectionInFlight; + const traceReady = authenticated && connectedInspectionNodes().length > 0 && !inspectionInFlight; const backendTargets = backendQuickTargets(); const allQuickBackends = allNodeItems.filter(item => isBackend(item) && item.online && item.acceptedCapabilities.includes('config.quick-setup.v1')).slice(0, MAX_CONFIGURATION_TARGETS); @@ -1594,7 +1597,7 @@ function updateExtendedButtons() { loadSiteHealth.disabled = !inspectionReady; loadVoteLogSummary.disabled = !inspectionReady; searchVoteLog.disabled = !inspectionReady; - traceVote.disabled = !inspectionReady; + traceVote.disabled = !traceReady; resolveSite.disabled = !inspectionReady; simulateReward.disabled = !inspectionReady; previewReward.disabled = !quickReady; @@ -1639,17 +1642,27 @@ function renderNodeViews() { updateExtendedButtons(); } -function resetServerConfigurationForms(status, preserveDirtyFile = false) { - configurationForm.reset(); - routingDirty = false; - if (preserveDirtyFile && configurationDirty) { +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.` + : status; +} + +function resetServerConfigurationForms(status, preserveDirtyDrafts = false) { + const retainedRoutingDraft = preserveDirtyDrafts && routingDirty; + if (!retainedRoutingDraft) { + configurationForm.reset(); + routingDirty = false; + routingDraftNodeId = ''; + } + if (preserveDirtyDrafts && configurationDirty) { lastFileReadOperation = null; approvedFilePreview = null; text(fileOperationStatus, `${status} Your unsaved ${configurationFile.value} draft is retained; explicitly switch files or load the current file to discard it.`); } else { resetFileEditorForSelection(status); } - text(operationStatus, status); + text(operationStatus, routingDraftStatus(status)); clearApprovals(); } @@ -1664,7 +1677,7 @@ function resetDedicatedSetupValues() { voteLoggingState.className = 'pill neutral'; } -function resetServerContextValues(reason, preserveDirtyFile = false) { +function resetServerContextValues(reason, preserveDirtyDrafts = false) { dedicatedSetupApprovals.clear(); pendingDetectedVoteSite = null; lastFileReadOperation = null; @@ -1694,7 +1707,7 @@ function resetServerContextValues(reason, preserveDirtyFile = false) { text(autoSitesStatus, reason); text(voteLoggingStatus, reason); loadedQuickSetup = null; - resetServerConfigurationForms(reason, preserveDirtyFile); + resetServerConfigurationForms(reason, preserveDirtyDrafts); const preset = quickPreset.value; quickSetupForm.reset(); quickPreset.value = preset; @@ -1720,7 +1733,8 @@ function selectPrimaryServer(nodeId) { function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 || proxyMethodWorkflowInFlight) { const primaryCapabilities = nodeCapabilities.get(selectedServerId) || []; const routingReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && - targets('config.proxy-routing.v1').length > 0 && !busy; + targets('config.proxy-routing.v1').length > 0 + && (!routingDirty || routingDraftNodeId === selectedServerId) && !busy; const fileCapability = selectedFileCapability(); const fileReady = authenticated && primaryCapabilities.includes(fileCapability) && fileTargetsForSelection().length > 0 && !busy; @@ -1922,6 +1936,7 @@ function discardAuthenticationState(reason) { configurationContentPresent = false; configurationDirty = false; routingDirty = false; + routingDraftNodeId = ''; autoLoadInFlight.clear(); quickSetupForm.reset(); resetDedicatedSetupValues(); @@ -2228,7 +2243,7 @@ async function loadNodes() { lastOverview = null; lastDiagnostics = null; inputGeneration++; - text(operationStatus, 'A selected node changed capabilities during refresh. Preview again before apply.'); + text(operationStatus, routingDraftStatus('A selected node changed capabilities during refresh. Preview again before apply.')); } const invalidRoutingApproval = approvedPreview && !approvedPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.proxy-routing.v1')); @@ -2248,7 +2263,7 @@ async function loadNodes() { if (invalidVoteSitesApproval) approvedQuickPreview = null; dedicatedSetupApprovals.clear(); inputGeneration++; - text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); + text(operationStatus, routingDraftStatus('A preview target went offline or lost the required capability. Preview again before apply.')); } const visibleIds = new Set(registry.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.') || value.startsWith('data.'))) .map(node => node.nodeId)); @@ -2269,7 +2284,7 @@ async function loadNodes() { approvedQuickPreview = null; dedicatedSetupApprovals.clear(); inputGeneration++; - text(operationStatus, 'The selected nodes changed during refresh. Preview again before apply.'); + text(operationStatus, routingDraftStatus('The selected nodes changed during refresh. Preview again before apply.')); } selectedNodes = filteredSelection; renderNodeViews(); @@ -2444,6 +2459,7 @@ async function loadProxyRouting(automatic = false) { blockedServers.value = retained.configuration.blockedServers.join('\n'); approvedPreview = null; routingDirty = false; + routingDraftNodeId = ''; inputGeneration++; updateConfigurationButtons(); } @@ -2481,10 +2497,14 @@ applyConfiguration.addEventListener('click', async () => { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }); - if (operation.state === 'SUCCEEDED') routingDirty = false; + if (operation.state === 'SUCCEEDED') { + routingDirty = false; + routingDraftNodeId = ''; + } } catch (error) { text(operationStatus, error.message); } }); [sendAll, blockedServers].forEach(field => field.addEventListener('input', () => { + if (!routingDirty) routingDraftNodeId = selectedServerId; routingDirty = true; if (approvedPreview) text(operationStatus, 'The proposal changed. Preview it again before apply.'); approvedPreview = null; 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 ec44132..6353359 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -80,7 +80,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Control enrollment unavailable")); assertTrue(script.body().contains("Comments preserved for every target")); assertTrue(script.body().contains("Backend topology is truncated")); - assertTrue(script.body().contains("function resetServerConfigurationForms(status, preserveDirtyFile = false)")); + assertTrue(script.body().contains("function resetServerConfigurationForms(status, preserveDirtyDrafts = false)")); assertTrue(script.body().contains("Network data is unavailable. Refresh and load current values before continuing.")); assertTrue(script.body().contains("backendTopologyTruncated = false;")); assertTrue(script.body().contains("nextPage.addEventListener")); @@ -127,6 +127,12 @@ class ControlHttpServerTest { assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); assertTrue(script.body().contains("voteId === voteTraceId.value.trim()")); + assertTrue(script.body().contains("const traceReady = authenticated && connectedInspectionNodes().length > 0")); + assertTrue(script.body().contains("traceVote.disabled = !traceReady;")); + assertTrue(script.body().contains("const retainedRoutingDraft = preserveDirtyDrafts && routingDirty;")); + assertTrue(script.body().contains("routingDraftNodeId === selectedServerId")); + assertTrue(script.body().contains("Your unsaved proxy-routing draft is retained")); + assertTrue(script.body().contains("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); assertTrue(script.body().contains("Discard unsaved ${configurationFile.value} changes and load the current file?")); From 208306a000e16685752f64355e88290c2ac584c7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:06:17 -0600 Subject: [PATCH 4/6] Complete Control inspection presentation --- src/main/resources/web/app.js | 47 +++++++++++++++++-- .../control/http/ControlHttpServerTest.java | 4 ++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 410e31e..bf39ee4 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -380,10 +380,42 @@ function renderPlayerData(value) { const totals = value.totals && typeof value.totals === 'object' ? value.totals : {}; add('Online', value.online === true ? 'Yes' : 'No'); add('Votes', `Daily ${totals.daily ?? 'Unknown'} · Weekly ${totals.weekly ?? 'Unknown'} · Monthly ${totals.monthly ?? 'Unknown'} · All-time ${totals.allTime ?? 'Unknown'}`); + const streaks = value.streaks && typeof value.streaks === 'object' ? value.streaks : {}; + add('Vote streaks', `Daily ${streaks.daily ?? 'Unknown'} · Weekly ${streaks.weekly ?? 'Unknown'} · Monthly ${streaks.monthly ?? 'Unknown'}`); add('Points', value.points ?? 'Unknown'); + add('Pending offline votes', value.pendingOfflineVotes ?? 'Unknown'); add('Last vote', formatEpoch(value.lastVoteTime)); add('Last online', formatEpoch(value.lastOnline)); playerResult.append(profile); + const lastVotes = Array.isArray(value.lastVotes) + ? value.lastVotes.filter(lastVote => lastVote && typeof lastVote === 'object').slice(0, 100) : []; + if (lastVotes.length) { + const heading = text(document.createElement('h4'), 'VoteSite history'); + const scroll = document.createElement('div'); + scroll.className = 'table-scroll'; + const table = document.createElement('table'); + const head = document.createElement('thead'); + const headRow = document.createElement('tr'); + ['Vote Site', 'ServiceSite', 'Last vote'].forEach(label => headRow.append(text(document.createElement('th'), label))); + head.append(headRow); + const body = document.createElement('tbody'); + lastVotes.forEach(lastVote => { + const row = document.createElement('tr'); + row.append(text(document.createElement('td'), lastVote.displayName || lastVote.siteKey || 'Unknown')); + row.append(text(document.createElement('td'), lastVote.serviceSite || 'Not configured')); + row.append(text(document.createElement('td'), formatEpoch(lastVote.time))); + body.append(row); + }); + table.append(head, body); + scroll.append(table); + playerResult.append(heading, scroll); + } + if (value.lastVotesTruncated === true) { + const warning = document.createElement('p'); + warning.className = 'warning-text'; + text(warning, 'Additional VoteSite history was omitted by the 100-row inspection limit.'); + playerResult.append(warning); + } if (!Array.isArray(value.columns)) return; const scroll = document.createElement('div'); scroll.className = 'table-scroll'; @@ -622,9 +654,15 @@ function renderVoteTrace(trace) { text(diagnostics, `Unavailable or failed sources: ${trace.unavailable.join('; ')}.`); voteTraceResult.append(diagnostics); } + if (trace.truncatedSources.length) { + const truncation = document.createElement('p'); + truncation.className = 'warning-text'; + text(truncation, `Node result limit reached; this trace is incomplete for: ${trace.truncatedSources.join(', ')}.`); + voteTraceResult.append(truncation); + } const boundary = document.createElement('p'); boundary.className = 'warning-text'; - text(boundary, 'This is the complete retained trace returned by the readable nodes above. It cannot show an internal hop that no node recorded.'); + text(boundary, 'This bounded view contains only events returned by the readable nodes above. A missing logged event is unknown or not logged, not proof that an internal hop failed.'); voteTraceResult.append(boundary); } @@ -651,6 +689,7 @@ async function traceVoteAcrossNodes() { const events = new Map(); const sources = []; const unavailable = []; + const truncatedSources = []; if (available.length > candidates.length) unavailable.push(`${available.length - candidates.length} additional capable nodes were omitted by the ${MAX_TRACE_NODES}-node trace limit`); inspectionInFlight = true; updateExtendedButtons(); @@ -662,7 +701,9 @@ async function traceVoteAcrossNodes() { const envelope = await runInspectionOnNode(node, 'vote-trace', {voteId, days, limit: '100'}, {deadlineAt: traceDeadline, manageBusy: false}); const listed = Array.isArray(envelope.result?.events) ? envelope.result.events : []; - sources.push(`${node.displayName} (${node.nodeId})`); + const source = `${node.displayName} (${node.nodeId})`; + sources.push(source); + if (envelope.result?.truncated === true) truncatedSources.push(source); listed.forEach(event => { if (!event || typeof event !== 'object') return; const key = traceEventKey(event); @@ -685,7 +726,7 @@ async function traceVoteAcrossNodes() { Number(left.event.voteTime || 0) - Number(right.event.voteTime || 0) || String(left.event.event || '').localeCompare(String(right.event.event || '')) || String(left.event.server || '').localeCompare(String(right.event.server || ''))); - renderVoteTrace({events: ordered, sources, unavailable}); + renderVoteTrace({events: ordered, sources, unavailable, truncatedSources}); } finally { inspectionInFlight = false; updateExtendedButtons(); 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 6353359..826d01f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -127,6 +127,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("operation.results?.[proxyId]?.sessionId !== proxySessionId")); assertTrue(script.body().contains("confirmDiscardUnsavedConfiguration('switching servers')")); assertTrue(script.body().contains("voteId === voteTraceId.value.trim()")); + assertTrue(script.body().contains("Pending offline votes")); + assertTrue(script.body().contains("Additional VoteSite history was omitted")); + assertTrue(script.body().contains("Node result limit reached; this trace is incomplete")); + assertFalse(script.body().contains("This is the complete retained trace")); assertTrue(script.body().contains("const traceReady = authenticated && connectedInspectionNodes().length > 0")); assertTrue(script.body().contains("traceVote.disabled = !traceReady;")); assertTrue(script.body().contains("const retainedRoutingDraft = preserveDirtyDrafts && routingDirty;")); From 4a433e64718f594e3bc276f3979fbb4dfee8e5c8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:19:56 -0600 Subject: [PATCH 5/6] Keep orphaned routing drafts recoverable --- src/main/resources/web/app.js | 12 ++++++------ .../control/http/ControlHttpServerTest.java | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index bf39ee4..d141d12 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1773,9 +1773,9 @@ function selectPrimaryServer(nodeId) { function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 || proxyMethodWorkflowInFlight) { const primaryCapabilities = nodeCapabilities.get(selectedServerId) || []; - const routingReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && - targets('config.proxy-routing.v1').length > 0 - && (!routingDirty || routingDraftNodeId === selectedServerId) && !busy; + const routingReadReady = authenticated && primaryCapabilities.includes('config.proxy-routing.v1') && + targets('config.proxy-routing.v1').length > 0 && !busy; + const routingDraftReady = routingReadReady && (!routingDirty || routingDraftNodeId === selectedServerId); const fileCapability = selectedFileCapability(); const fileReady = authenticated && primaryCapabilities.includes(fileCapability) && fileTargetsForSelection().length > 0 && !busy; @@ -1783,9 +1783,9 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | const quickReady = authenticated && !busy && (syncSelected ? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0) : primaryCapabilities.includes('config.quick-setup.v1') && targets('config.quick-setup.v1').length > 0); - readConfiguration.disabled = !routingReady; - previewConfiguration.disabled = !routingReady; - applyConfiguration.disabled = !routingReady || !approvedPreview; + readConfiguration.disabled = !routingReadReady; + previewConfiguration.disabled = !routingDraftReady; + applyConfiguration.disabled = !routingDraftReady || !approvedPreview; readFileConfiguration.disabled = !fileReady; previewFileConfiguration.disabled = !fileReady || !configurationContentPresent; applyFileConfiguration.disabled = !fileReady || !approvedFilePreview; 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 826d01f..4b9e421 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -135,6 +135,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("traceVote.disabled = !traceReady;")); assertTrue(script.body().contains("const retainedRoutingDraft = preserveDirtyDrafts && routingDirty;")); assertTrue(script.body().contains("routingDraftNodeId === selectedServerId")); + assertTrue(script.body().contains("readConfiguration.disabled = !routingReadReady;")); + 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("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); From 574613a2631a01faaadb7cd85c6c37acf27836c9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:53:32 -0600 Subject: [PATCH 6/6] Protect trace and YAML draft contexts --- docs/control-management.md | 3 +- src/main/resources/web/app.js | 142 +++++++++++++++--- .../control/http/ControlHttpServerTest.java | 23 ++- 3 files changed, 140 insertions(+), 28 deletions(-) diff --git a/docs/control-management.md b/docs/control-management.md index 6640177..2df6c29 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -60,6 +60,7 @@ authenticated, CSRF-protected endpoint and node capability checks as an external | 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 | | Fast file reads | Caches a successful file read for 30 seconds by node ID, node session, and file | Browser memory only; cleared on logout and successful relevant writes; session binding prevents reuse after reconnect | +| Full-YAML drafts | Keeps unsaved editor contents during a registry refresh | A dirty draft is bound to its source node, session, and file; it cannot preview or apply after that session changes. The operator must explicitly confirm a current-file read/reload, which discards the retained draft and rebinds the editor. | | Proxy configuration | Opens `bungeeconfig.yml` only for the selected online proxy that negotiated `config.proxy-files.v1` | Fixed one-file capability, never proxy file browsing; redacted READ, PREVIEW, and one-time approved APPLY still apply | | Configuration drift | Reads the same redacted managed file from two or more selected capable nodes, groups exact revisions, and compares each target with the first successful baseline | Read-only; renders at most 50 differing line pairs per target and truncates each redacted line to 200 characters | | Snapshots | Creates a named durable snapshot from the last completed file read and loads one document into the editor | Stores the full redacted read result; restore is proposed content and must be freshly previewed/approved | @@ -69,7 +70,7 @@ authenticated, CSRF-protected endpoint and node capability checks as an external | Vote-logging setup | Reads/previews/applies enabled state, retention, and main-connection choice | Never accepts credentials; dedicated connection details stay in the redacted editor | | Setup profiles | Stores up to 20 named guided-form profiles in browser `localStorage` | Browser-local, versioned, non-secret values only; no raw YAML or credentials; loading never applies | | Reward builder/simulator | Builds site/every-site/vote-party proposals with commands, player/broadcast messages, items, money, permissions, chance, and online-only behavior; simulates or previews/applies the exact proposal; can copy one command/message into simple Setup | Simulation has no side effects; persistence replaces only the selected Rewards subtree through normal preview/approval; editing invalidates approval | -| Votes & Data | Shows overview, exact player lookup, vote-site health plus persisted unconfigured-service observations, a 30-day VoteLog summary, exact/bounded logged-event search, and correlation trace | Inspection-capable Bukkit node only; VoteLog reads require logging; results and form inputs are cleared on logout; no player enumeration | +| Votes & Data | Shows overview, exact player lookup, vote-site health plus persisted unconfigured-service observations, a 30-day VoteLog summary, exact/bounded logged-event search, and correlation trace | Inspection-capable Bukkit node only; trace starts its bounded node set concurrently under one 90-second budget, so a stalled node cannot delay healthy sources; VoteLog reads require logging; results and form inputs are cleared on logout; no player enumeration | | Safe service-site test | Dry-runs resolution, including optional disabled-site matching and whether auto-create would be considered | Sends no fake vote, creates no site, changes no total, and runs no reward | The Setup tab replaces the former “Quick Setup” framing but retains existing typed presets, VoteSites sync, detected-plugin diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index d141d12..cc3dd03 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -203,6 +203,7 @@ const MAX_CONFIGURATION_TARGETS = 100; const MAX_SYNC_TARGETS = 100; const MAX_OPERATION_TARGETS = 100; const MAX_TRACE_NODES = 12; +const MAX_TRACE_EVENTS_PER_NODE = 100; const TRACE_DEADLINE_MS = 90_000; const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; @@ -249,6 +250,9 @@ let fileReadCache = new Map(); let lastFileReadOperation = null; let configurationContentPresent = false; let configurationDirty = false; +let configurationDraftNodeId = ''; +let configurationDraftSessionId = ''; +let configurationDraftFileName = ''; let routingDirty = false; let routingDraftNodeId = ''; let configurationFileSelection = configurationFile.value; @@ -572,19 +576,40 @@ async function runInspectionOnNode(node, kind, filters = {}, options = {}) { inspectionInFlight = true; updateExtendedButtons(); } + const requestedDeadline = Number(options.deadlineAt); + const deadline = Number.isFinite(requestedDeadline) + ? Math.min(Date.now() + 180_000, requestedDeadline) + : Date.now() + 180_000; + const ensureActive = () => { + if (typeof options.contextCurrent === 'function' && !options.contextCurrent()) { + throw new Error('The trace context changed while an inspection was running.'); + } + if (Date.now() >= deadline || options.signal?.aborted) { + throw new Error('Inspection did not finish within this request budget.'); + } + }; + const request = async (path, requestOptions = {}) => { + ensureActive(); + try { + const response = await authorized(path, {...requestOptions, signal: options.signal}); + ensureActive(); + return response; + } catch (error) { + if (Date.now() >= deadline || options.signal?.aborted) { + throw new Error('Inspection did not finish within this request budget.'); + } + throw error; + } + }; try { - let inspection = await authorized('/api/v1/inspections', { + let inspection = await request('/api/v1/inspections', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({nodeId, query: {kind, filters: boundedFilters}}) }); - const requestedDeadline = Number(options.deadlineAt); - const deadline = Number.isFinite(requestedDeadline) - ? Math.min(Date.now() + 180_000, requestedDeadline) - : Date.now() + 180_000; while (inspection.state === 'RUNNING') { - if (Date.now() >= deadline) throw new Error('Inspection did not finish within this request budget.'); + ensureActive(); await new Promise(resolve => window.setTimeout(resolve, Math.min(1000, Math.max(0, deadline - Date.now())))); - inspection = await authorized(`/api/v1/inspections/${inspection.inspectionId}`); + inspection = await request(`/api/v1/inspections/${inspection.inspectionId}`); } if (requestAuthenticationGeneration !== authenticationGeneration || sessionId !== nodeIndex.get(nodeId)?.sessionId) { throw new Error('The node reconnected or the session changed while the trace ran.'); @@ -685,7 +710,11 @@ async function traceVoteAcrossNodes() { && requestInputGeneration === inputGeneration && requestSelectedNodeId === selectedServerId && requestSelectedSessionId === nodeIndex.get(requestSelectedNodeId)?.sessionId && voteId === voteTraceId.value.trim() && days === String(voteLogDays.value) - && candidates.every(node => candidateSessions.get(node.nodeId) === nodeIndex.get(node.nodeId)?.sessionId); + && candidates.every(node => { + const current = nodeIndex.get(node.nodeId); + return Boolean(current?.online) && candidateSessions.get(node.nodeId) === current.sessionId + && Array.isArray(current.acceptedCapabilities) && current.acceptedCapabilities.includes('data.inspect.v1'); + }); const events = new Map(); const sources = []; const unavailable = []; @@ -694,13 +723,26 @@ async function traceVoteAcrossNodes() { inspectionInFlight = true; updateExtendedButtons(); text(voteTraceResult, `Collecting retained events from ${candidates.length} connected backend ${candidates.length === 1 ? 'node' : 'nodes'}…`); + const traceAbortController = new AbortController(); + const abortTrace = () => { + if (!traceAbortController.signal.aborted) traceAbortController.abort(); + }; + const deadlineTimer = window.setTimeout(abortTrace, Math.max(0, traceDeadline - Date.now())); + const contextTimer = window.setInterval(() => { + if (!contextCurrent()) abortTrace(); + }, 250); try { - for (const node of candidates) { - if (!contextCurrent() || Date.now() >= traceDeadline) break; - try { - const envelope = await runInspectionOnNode(node, 'vote-trace', {voteId, days, limit: '100'}, - {deadlineAt: traceDeadline, manageBusy: false}); - const listed = Array.isArray(envelope.result?.events) ? envelope.result.events : []; + const results = await Promise.allSettled(candidates.map(async node => { + const envelope = await runInspectionOnNode(node, 'vote-trace', {voteId, days, limit: String(MAX_TRACE_EVENTS_PER_NODE)}, { + deadlineAt: traceDeadline, signal: traceAbortController.signal, contextCurrent, manageBusy: false + }); + return {node, envelope}; + })); + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + const {node, envelope} = result.value; + const listed = Array.isArray(envelope.result?.events) + ? envelope.result.events.slice(0, MAX_TRACE_EVENTS_PER_NODE) : []; const source = `${node.displayName} (${node.nodeId})`; sources.push(source); if (envelope.result?.truncated === true) truncatedSources.push(source); @@ -711,23 +753,28 @@ async function traceVoteAcrossNodes() { retained.sources.push(`${node.displayName} (${node.nodeId})`); events.set(key, retained); }); - } catch (error) { + } else { + const node = candidates[index]; + const error = result.reason; unavailable.push(`${node.displayName} (${node.nodeId}): ${error.message || 'inspection failed'}`); } - } + }); if (!contextCurrent()) { if (requestAuthenticationGeneration === authenticationGeneration) { text(voteTraceResult, 'The selected server, node session, or trace window changed. Run the trace again.'); } return; } - if (Date.now() >= traceDeadline) unavailable.push('The remaining nodes were omitted after the 90-second trace budget expired'); + if (Date.now() >= traceDeadline) unavailable.push('The 90-second trace budget expired before one or more node inspections completed'); const ordered = [...events.values()].map(item => ({...item, sources: [...new Set(item.sources)].sort()})).sort((left, right) => Number(left.event.voteTime || 0) - Number(right.event.voteTime || 0) || String(left.event.event || '').localeCompare(String(right.event.event || '')) || String(left.event.server || '').localeCompare(String(right.event.server || ''))); renderVoteTrace({events: ordered, sources, unavailable, truncatedSources}); } finally { + window.clearTimeout(deadlineTimer); + window.clearInterval(contextTimer); + abortTrace(); inspectionInFlight = false; updateExtendedButtons(); } @@ -995,6 +1042,9 @@ function applyAuthenticatedSession(body) { configurationContent.value = ''; configurationContentPresent = false; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; routingDirty = false; routingDraftNodeId = ''; configurationFileSelection = configurationFile.value; @@ -1185,12 +1235,29 @@ function resetFileEditorForSelection(message) { configurationContent.value = ''; configurationContentPresent = false; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; lastFileReadOperation = null; approvedFilePreview = null; updateEditorPosition(); text(fileOperationStatus, message); } +function fileDraftMatchesCurrentContext() { + return !configurationDirty || configurationDraftNodeId === selectedServerId + && configurationDraftSessionId === nodeIndex.get(selectedServerId)?.sessionId + && configurationDraftFileName === configurationFile.value; +} + +function fileDraftStatus(status) { + if (!configurationDirty) return status; + const owner = configurationDraftNodeId + ? `${configurationDraftNodeId}${configurationDraftSessionId ? ` (session ${configurationDraftSessionId})` : ''}` + : 'the previous server'; + return `${status} Your unsaved ${configurationFile.value} draft is retained for ${owner}; read/reload the current file to explicitly discard it and bind the editor to this server.`; +} + function confirmDiscardUnsavedConfiguration(context) { if (!configurationDirty && !routingDirty) return true; return window.confirm(`Discard unsaved ${configurationDirty && routingDirty ? 'YAML and routing' : configurationDirty ? 'YAML' : 'routing'} changes before ${context}?`); @@ -1206,8 +1273,9 @@ function syncFileSelection() { const expected = proxySelected ? 'bungeeconfig.yml' : 'Config.yml'; if ((proxySelected && configurationFile.value !== 'bungeeconfig.yml') || (!proxySelected && configurationFile.value === 'bungeeconfig.yml')) { - if (!proxySelected && configurationFile.value === 'bungeeconfig.yml' && configurationDirty) { - text(fileOperationStatus, 'The selected proxy is no longer available for this file. Your unsaved draft is retained; reconnect the proxy or explicitly switch files to discard it.'); + if (configurationDirty) { + text(fileOperationStatus, fileDraftStatus( + 'The selected server cannot manage this file. Reconnect the original server or explicitly switch files to discard it.')); return; } configurationFile.value = expected; @@ -1699,7 +1767,7 @@ function resetServerConfigurationForms(status, preserveDirtyDrafts = false) { if (preserveDirtyDrafts && configurationDirty) { lastFileReadOperation = null; approvedFilePreview = null; - text(fileOperationStatus, `${status} Your unsaved ${configurationFile.value} draft is retained; explicitly switch files or load the current file to discard it.`); + text(fileOperationStatus, fileDraftStatus(status)); } else { resetFileEditorForSelection(status); } @@ -1779,6 +1847,7 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | const fileCapability = selectedFileCapability(); const fileReady = authenticated && primaryCapabilities.includes(fileCapability) && fileTargetsForSelection().length > 0 && !busy; + const fileDraftReady = fileReady && fileDraftMatchesCurrentContext(); const syncSelected = quickPreset.value === 'sync-vote-sites'; const quickReady = authenticated && !busy && (syncSelected ? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0) @@ -1787,8 +1856,8 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | previewConfiguration.disabled = !routingDraftReady; applyConfiguration.disabled = !routingDraftReady || !approvedPreview; readFileConfiguration.disabled = !fileReady; - previewFileConfiguration.disabled = !fileReady || !configurationContentPresent; - applyFileConfiguration.disabled = !fileReady || !approvedFilePreview; + previewFileConfiguration.disabled = !fileDraftReady || !configurationContentPresent; + applyFileConfiguration.disabled = !fileDraftReady || !approvedFilePreview; readQuickSetup.disabled = !quickReady || !quickPresetReadable(); previewQuickSetup.disabled = !quickReady || (quickPresetNeedsRead() && !quickSetupValuesLoaded()); applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; @@ -1976,6 +2045,9 @@ function discardAuthenticationState(reason) { configurationFileSelection = configurationFile.value; configurationContentPresent = false; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; routingDirty = false; routingDraftNodeId = ''; autoLoadInFlight.clear(); @@ -2555,7 +2627,7 @@ applyConfiguration.addEventListener('click', async () => { async function loadFileConfiguration(automatic = false) { if (!automatic && configurationDirty - && !window.confirm(`Discard unsaved ${configurationFile.value} changes and load the current file?`)) return; + && !window.confirm(`Discard the unsaved ${configurationFile.value} draft and read/reload the current file for this server?`)) return; approvedFilePreview = null; const readAuthenticationGeneration = authenticationGeneration; const readInputGeneration = inputGeneration; @@ -2573,6 +2645,9 @@ async function loadFileConfiguration(automatic = false) { configurationContent.value = cached.content; configurationContentPresent = true; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; lastFileReadOperation = {operationId: cached.operationId}; updateEditorPosition(); text(fileOperationStatus, `Cached read · ${selectedServerId} · ${selectedFile}\nLoaded instantly; cache expires after 30 seconds. Preview still checks the live revision.`); @@ -2594,6 +2669,9 @@ async function loadFileConfiguration(automatic = false) { configurationContent.value = contentResult.configuration.content; configurationContentPresent = true; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; lastFileReadOperation = {operationId: operation.operationId}; cacheFile(cacheKey, contentResult.configuration.content, operation.operationId); updateEditorPosition(); @@ -2609,6 +2687,11 @@ readFileConfiguration.addEventListener('click', () => { void loadFileConfigurati previewFileConfiguration.addEventListener('click', async () => { approvedFilePreview = null; + if (!fileDraftMatchesCurrentContext()) { + text(fileOperationStatus, fileDraftStatus('This draft belongs to a different node session and cannot be previewed here.')); + updateConfigurationButtons(); + return; + } const previewGeneration = inputGeneration; const selectedFile = configurationFile.value; const previewTargets = fileTargetsForSelection(selectedFile); @@ -2636,7 +2719,7 @@ previewFileConfiguration.addEventListener('click', async () => { applyFileConfiguration.addEventListener('click', async () => { const currentTargets = fileTargetsForSelection(configurationFile.value); - if (!approvedFilePreview || approvedFilePreview.fileName !== configurationFile.value + if (!fileDraftMatchesCurrentContext() || !approvedFilePreview || approvedFilePreview.fileName !== configurationFile.value || !approvedFilePreview.nodeIds.every(nodeId => approvedFilePreview.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId) || currentTargets.length !== approvedFilePreview.nodeIds.length || !approvedFilePreview.nodeIds.every(nodeId => currentTargets.includes(nodeId)) @@ -2653,6 +2736,9 @@ applyFileConfiguration.addEventListener('click', async () => { fileReadCache.clear(); lastFileReadOperation = null; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; updateExtendedButtons(); } } catch (error) { text(fileOperationStatus, error.message); } @@ -3253,6 +3339,9 @@ async function loadSnapshots() { configurationContent.value = document.content; configurationContentPresent = true; configurationDirty = false; + configurationDraftNodeId = ''; + configurationDraftSessionId = ''; + configurationDraftFileName = ''; lastFileReadOperation = null; updateEditorPosition(); approvedFilePreview = null; @@ -3492,6 +3581,11 @@ quickName.addEventListener('input', () => { updateQuickFields(); }); configurationContent.addEventListener('input', () => { + if (!configurationDirty) { + configurationDraftNodeId = selectedServerId; + configurationDraftSessionId = nodeIndex.get(selectedServerId)?.sessionId || ''; + configurationDraftFileName = configurationFile.value; + } configurationContentPresent = true; configurationDirty = true; clearApprovals(); 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 4b9e421..222460c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -111,14 +111,31 @@ class ControlHttpServerTest { assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertTrue(script.body().contains("let configurationContentPresent = false;")); + assertTrue(script.body().contains("const MAX_TRACE_EVENTS_PER_NODE = 100;")); + assertTrue(script.body().contains("const traceAbortController = new AbortController();")); + assertTrue(script.body().contains("await Promise.allSettled(candidates.map(async node => {")); + assertTrue(script.body().contains("const response = await authorized(path, {...requestOptions, signal: options.signal});\n ensureActive();")); + assertTrue(script.body().contains("current.acceptedCapabilities.includes('data.inspect.v1')")); assertTrue(script.body().contains( - "previewFileConfiguration.disabled = !fileReady || !configurationContentPresent;")); + "signal: traceAbortController.signal, contextCurrent, manageBusy: false")); + assertTrue(script.body().contains("window.clearTimeout(deadlineTimer);")); + assertFalse(script.body().contains("for (const node of candidates)")); + assertTrue(script.body().contains("let configurationDraftNodeId = '';")); + assertTrue(script.body().contains("let configurationDraftSessionId = '';")); + assertTrue(script.body().contains("function fileDraftMatchesCurrentContext()")); + assertTrue(script.body().contains("if (configurationDirty) {\n text(fileOperationStatus, fileDraftStatus("), + "Routine refresh must retain dirty drafts when the replacement has a different node role."); + assertTrue(script.body().contains("const fileDraftReady = fileReady && fileDraftMatchesCurrentContext();")); + assertTrue(script.body().contains( + "previewFileConfiguration.disabled = !fileDraftReady || !configurationContentPresent;")); + assertTrue(script.body().contains( + "applyFileConfiguration.disabled = !fileDraftReady || !approvedFilePreview;")); assertTrue(script.body().contains( "configurationContent.value = document.content;\n configurationContentPresent = true;")); assertTrue(script.body().contains( "resetServerContextValues('A selected server reconnected. Load current values before continuing.', true);")); assertTrue(script.body().contains( - "configurationContent.addEventListener('input', () => {\n configurationContentPresent = true;")); + "configurationContent.addEventListener('input', () => {\n if (!configurationDirty) {\n configurationDraftNodeId = selectedServerId;")); assertTrue(script.body().contains("quickPresetNeedsRead() && !quickSetupValuesLoaded()")); assertTrue(script.body().contains("loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId")); @@ -142,7 +159,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("text(operationStatus, routingDraftStatus('The selected nodes changed during refresh.")); assertTrue(script.body().contains("Your unsaved ${configurationFile.value} draft is retained")); assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); - assertTrue(script.body().contains("Discard unsaved ${configurationFile.value} changes and load the current file?")); + assertTrue(script.body().contains("Discard the unsaved ${configurationFile.value} draft and read/reload the current file for this server?")); assertTrue(script.body().contains("window.addEventListener('beforeunload'")); assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset, selector}")); assertTrue(script.body().contains("configurationOperationsInFlight"));