From db294c84f719c8b65a71f0cd8a66a0fc8d4612ff Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:04:28 -0600 Subject: [PATCH 01/26] Redesign hosted WebUI: index.html --- src/main/resources/web/index.html | 420 ++++++++++++++++++++---------- 1 file changed, 276 insertions(+), 144 deletions(-) diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 4e272c04..030f0e5a 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -7,16 +7,31 @@ -
-
-
-

Local network console

-

VotingPlugin Control

-
+
+ + + VotingPluginControl + +
+ Connecting… -
+ + +
+ +
+
+

Hosted network console

+

Manage VotingPlugin without losing sight of the network.

+

Choose a VotingPlugin node once, then move between its configuration, setup, and network context.

+
- -
-
-
-

Quick setups

-

Generate a safe preview for common backend and vote-site setups, then approve the exact result.

+
-
- - -

Select Bukkit nodes to see detected plugins and command suggestions.

-
- + +
+
+
⚙

Common settings

Use guided fields for frequently changed VotingPlugin options.

+
◇

Vote sites

Edit the selected server's complete VoteSites.yml today.

+
↗

Proxy connection

Inspect proxy relationships before changing backend routing.

+
-
- - - + -
- - - - + -
- Common settings - - - - - - -
-
- - - - - +
+ + + + +
+ ✓ +

Operation results stay with each workflow

+

Read, preview, apply, reload, and rollback results remain visible beside the configuration that produced them. A network-wide audit timeline will land here with the next operation protocol.

+
+ + + +
From 0eb42e03331fa6eb13329e7cb3a4f687ed59068c Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:04:30 -0600 Subject: [PATCH 02/26] Redesign hosted WebUI: app.css --- src/main/resources/web/app.css | 218 +++++++++++++++++++++++++++------ 1 file changed, 179 insertions(+), 39 deletions(-) diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index 43cf8c75..ab09fc9f 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -1,55 +1,195 @@ :root { color-scheme: dark; - font-family: Inter, ui-sans-serif, system-ui, sans-serif; - background: #08111f; - color: #e7eef9; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #07101d; + color: #e8f0fb; + --surface: #0c1828; + --surface-raised: #102035; + --surface-soft: #13253b; + --border: #253b56; + --border-strong: #365475; + --muted: #9fb0c5; + --blue: #4b9cff; + --blue-soft: #112f51; + --green: #79e6a3; + --green-soft: #0d3325; + --amber: #ffc96b; + --amber-soft: #37270d; + --danger: #ff89a4; } * { box-sizing: border-box; } -body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #163158 0, #08111f 45%); } -main { width: min(1040px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 80px; } -header, .section-title, .auth-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.auth-actions { display: flex; gap: 10px; } +[hidden] { display: none !important; } +body { margin: 0; min-height: 100vh; background: #07101d radial-gradient(circle at 50% -20%, #17375e 0, transparent 42rem); } +button, input, textarea, select { font: inherit; } +button { padding: 11px 16px; border: 1px solid #4b9cff; border-radius: 9px; background: #3187ee; color: #fff; font-weight: 750; cursor: pointer; transition: border-color .15s, background .15s, transform .15s; } +button:hover:not(:disabled) { background: #4496f6; transform: translateY(-1px); } +button:disabled { opacity: .46; cursor: not-allowed; } +button.secondary { border-color: var(--border-strong); background: transparent; color: #d6e5f7; } +button.secondary:hover:not(:disabled) { background: var(--surface-soft); border-color: #53769d; } +button.compact { padding: 9px 12px; } + +.topbar { position: sticky; z-index: 10; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 76px; padding: 12px max(24px, calc((100vw - 1240px) / 2)); border-bottom: 1px solid rgba(54, 84, 117, .72); background: rgba(7, 16, 29, .9); backdrop-filter: blur(18px); } +.brand { display: flex; align-items: center; gap: 11px; color: inherit; text-decoration: none; } +.brand-mark { display: grid; width: 40px; height: 40px; place-items: center; border-radius: 11px; background: linear-gradient(145deg, #5babff, #246ac4); box-shadow: 0 8px 30px rgba(49, 135, 238, .24); font-size: 1.2rem; font-weight: 900; } +.brand strong, .brand small { display: block; line-height: 1.05; } +.brand small { margin-top: 4px; color: var(--muted); font-size: .72rem; letter-spacing: .13em; text-transform: uppercase; } +.topbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; } +.server-picker { display: grid; grid-template-columns: auto minmax(190px, 280px); align-items: center; gap: 9px; margin: 0; color: var(--muted); font-size: .78rem; } +.server-picker select { margin: 0; padding: 9px 35px 9px 11px; } +.status { padding: 8px 12px; border: 1px solid var(--border-strong); border-radius: 999px; color: var(--muted); font-size: .78rem; white-space: nowrap; } +.status.online { color: var(--green); border-color: #2b7750; background: var(--green-soft); } + +main { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding: 48px 0 80px; } +.welcome { max-width: 780px; margin: 18px 0 34px; } +.welcome h1 { margin-bottom: 12px; font-size: clamp(2.2rem, 5vw, 4rem); line-height: 1.04; letter-spacing: -.045em; } +.welcome > p:last-child { max-width: 660px; color: var(--muted); font-size: 1.08rem; line-height: 1.7; } h1, h2, h3, p { margin-top: 0; } -h1 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 3.5rem); } -.eyebrow { margin-bottom: 6px; color: #72b7ff; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } -.status { border: 1px solid #38506f; border-radius: 999px; padding: 9px 14px; color: #aabbd0; } -.status.online { color: #8ef0b1; border-color: #28734b; background: #0c2c20; } -.card { margin-top: 24px; padding: 24px; border: 1px solid #263b57; border-radius: 18px; background: rgba(10, 25, 45, .88); box-shadow: 0 18px 60px rgba(0, 0, 0, .22); } +h2 { margin-bottom: 10px; font-size: clamp(1.65rem, 3vw, 2.35rem); letter-spacing: -.03em; } +h3 { margin-bottom: 7px; } +p { line-height: 1.55; } +.eyebrow { margin-bottom: 7px; color: #72b7ff; font-size: .73rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } + +.card { padding: 24px; border: 1px solid var(--border); border-radius: 15px; background: rgba(12, 24, 40, .92); box-shadow: 0 18px 60px rgba(0, 0, 0, .16); } +.auth-card { max-width: 720px; } +.page-heading, .section-title, .auth-row, .editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 18px; } +.page-heading { margin: 30px 0 20px; } +.page-heading p:not(.eyebrow), .section-title p { margin-bottom: 0; color: var(--muted); } +.section-title { align-items: flex-start; } +.section-title > div { min-width: 0; } + +.tabs { display: flex; gap: 4px; margin: 0 -20px; padding: 0 20px; overflow-x: auto; border-bottom: 1px solid var(--border); scrollbar-width: thin; } +.tabs button { flex: 0 0 auto; padding: 14px 15px; border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: transparent; color: var(--muted); } +.tabs button:hover:not(:disabled) { background: transparent; color: #fff; transform: none; } +.tabs button[aria-selected="true"] { border-bottom-color: var(--blue); color: #fff; } +.tab-panel { animation: enter .16s ease-out; } +@keyframes enter { from { opacity: 0; transform: translateY(3px); } } + +.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; margin-bottom: 18px; } +.metrics article { display: flex; min-height: 112px; flex-direction: column; justify-content: space-between; padding: 19px; border: 1px solid var(--border); border-radius: 13px; background: var(--surface); } +.metrics span { color: var(--muted); font-size: .85rem; } +.metrics strong { font-size: 2rem; } +.selected-summary-card { margin-bottom: 18px; background: linear-gradient(135deg, rgba(17, 47, 81, .88), rgba(12, 24, 40, .96)); } +.selected-summary-card > p { color: var(--muted); } +.pills { display: flex; flex-wrap: wrap; gap: 7px; } +.pill { display: inline-flex; align-items: center; width: fit-content; padding: 5px 9px; border: 1px solid #37628d; border-radius: 999px; background: var(--blue-soft); color: #a9d4ff; font-size: .74rem; font-weight: 750; } +.pill.online { border-color: #2b7750; background: var(--green-soft); color: var(--green); } +.pill.offline { border-color: #82445a; background: #341521; color: var(--danger); } +.pill.warning { border-color: #785927; background: var(--amber-soft); color: var(--amber); } +.pill.neutral { border-color: var(--border-strong); background: #111e30; color: var(--muted); } + +.topology { display: grid; gap: 11px; } +.topology.empty, .nodes.empty { display: block; padding: 30px; border: 1px dashed var(--border-strong); border-radius: 11px; color: var(--muted); text-align: center; } +.topology-row { display: grid; grid-template-columns: minmax(170px, .72fr) minmax(260px, 1.5fr); gap: 12px; align-items: stretch; } +.topology-proxy, .topology-backends { padding: 16px; border: 1px solid var(--border); border-radius: 11px; background: #0a1524; } +.topology-proxy { display: flex; flex-direction: column; justify-content: center; } +.topology-proxy small, .topology-backends small { color: var(--muted); } +.topology-backends { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.topology-link { display: inline-flex; flex-direction: column; gap: 2px; min-width: 150px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); } +.topology-link.online { border-color: #2b7750; } +.topology-link.warning { border-color: #785927; } + +.nodes { display: grid; grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); gap: 14px; } +.node { position: relative; padding: 20px; border: 1px solid var(--border); border-radius: 13px; background: var(--surface); } +.node.selected { border-color: #4b9cff; box-shadow: inset 0 0 0 1px #4b9cff, 0 15px 40px rgba(32, 99, 173, .12); } +.node-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.node-header h3 { margin-bottom: 2px; } +.node-id { color: var(--muted); font-size: .78rem; } +.node-meta { display: flex; flex-wrap: wrap; gap: 7px; margin: 14px 0; } +.node-detail { margin: 10px 0 0; color: var(--muted); font-size: .88rem; } +.node-select { display: flex; align-items: center; gap: 8px; margin: 15px 0 0; color: var(--muted); font-size: .8rem; font-weight: 650; } +.node-select input, .check-row input { flex: none; width: 18px; height: 18px; } +.node-backends { margin-top: 15px; } +.node-backends > li { gap: 5px; } +.backend-state { display: flex; flex-wrap: wrap; gap: 5px; color: var(--muted); font-size: .84rem; } +.backend-state span + span::before { margin-right: 5px; content: "·"; color: #53677f; } + +.roadmap-card { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; margin-bottom: 18px; padding: 0; overflow: hidden; } +.roadmap-card > div { display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; padding: 19px; border-right: 1px solid var(--border); } +.roadmap-card > div:last-child { border: 0; } +.roadmap-card small { grid-column: 1 / -1; color: var(--muted); } +.step { align-self: center; padding: 3px 6px; border: 1px solid #785927; border-radius: 5px; color: var(--amber); font-size: .62rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +#configuration-card { margin-top: 0; } + +.subtabs { display: flex; gap: 7px; padding: 5px; border: 1px solid var(--border); border-radius: 11px; background: #091421; } +.subtabs button { flex: 1; border-color: transparent; background: transparent; color: var(--muted); } +.subtabs button:hover:not(:disabled) { background: var(--surface-soft); transform: none; } +.subtabs button.active { border-color: var(--border); background: var(--surface-raised); color: #fff; } +.config-view { margin-top: 17px; } +.settings-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } +.settings-grid .card { display: flex; min-height: 240px; flex-direction: column; align-items: flex-start; } +.settings-grid .card p { color: var(--muted); } +.settings-grid .card button { margin-top: auto; } +.setting-icon { display: grid; width: 39px; height: 39px; margin-bottom: 16px; place-items: center; border: 1px solid #37628d; border-radius: 10px; background: var(--blue-soft); color: #a9d4ff; font-size: 1.1rem; } +.editor-card { padding: 0; overflow: hidden; } +.editor-card > .section-title { padding: 21px 22px 17px; border-bottom: 1px solid var(--border); } +.editor-toolbar { display: grid; grid-template-columns: auto minmax(180px, 280px) 1fr; align-items: center; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--border); background: #091421; } +.editor-toolbar label { margin: 0; } +.editor-toolbar select { margin: 0; } +.editor-toolbar > span { justify-self: end; color: var(--muted); font-size: .78rem; } +.yaml-editor { display: block; width: 100%; min-height: 520px; margin: 0; padding: 20px 22px; border: 0; border-radius: 0; background: #050c16; color: #dbeaff; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: .88rem; line-height: 1.55; resize: vertical; tab-size: 2; } +.yaml-editor:focus { outline: 2px solid var(--blue); outline-offset: -2px; } +.editor-footer { padding: 13px 18px; border-top: 1px solid var(--border); background: #091421; } +.editor-footer > span { color: var(--muted); font-size: .8rem; } +.empty-state { display: grid; justify-items: center; min-height: 300px; align-content: center; text-align: center; } +.empty-state p { max-width: 610px; color: var(--muted); } + label { display: block; margin-bottom: 8px; font-weight: 700; } -input, textarea, select { flex: 1; min-width: 0; padding: 12px 14px; border: 1px solid #38506f; border-radius: 10px; background: #07101d; color: inherit; } -button { padding: 12px 16px; border: 0; border-radius: 10px; background: #3187ee; color: white; font-weight: 700; cursor: pointer; } -button:disabled { opacity: .5; cursor: not-allowed; } -#message, #setup-message, #enrollment-message { min-height: 1.5em; margin: 14px 0 0; color: #aabbd0; } +input, textarea, select { flex: 1; min-width: 0; padding: 11px 13px; border: 1px solid var(--border-strong); border-radius: 9px; outline: none; background: #060e19; color: inherit; } +input:focus, textarea:focus, select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(75, 156, 255, .16); } +textarea { width: 100%; margin-bottom: 14px; resize: vertical; } +select { width: 100%; margin-bottom: 16px; } +.auth-row input { min-height: 43px; } #setup-form > input { width: 100%; margin-bottom: 14px; } -#enrollment-credential { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +#message, #setup-message, #enrollment-message { min-height: 1.5em; margin: 14px 0 0; color: var(--muted); } +#enrollment-credential { font-family: "SFMono-Regular", Consolas, monospace; } +.enrollment-title { margin-top: 18px; } .enrollment-item { flex-direction: row; align-items: center; justify-content: space-between; gap: 12px; } -.enrollment-item button { padding: 8px 12px; background: #8f3550; } -.nodes { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 16px; } -.nodes.empty { display: block; padding: 28px; border: 1px dashed #38506f; border-radius: 12px; color: #aabbd0; text-align: center; } -.node { padding: 18px; border: 1px solid #38506f; border-radius: 12px; background: #0b192b; } -.node-select { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } -.node-select input, .check-row input { flex: none; width: 18px; height: 18px; } +.enrollment-item button { padding: 8px 12px; border-color: #8f3550; background: #8f3550; } .check-row { display: flex; align-items: center; gap: 10px; } -label span { color: #aabbd0; font-weight: 400; } -textarea { width: 100%; resize: vertical; margin-bottom: 14px; } -.operation-actions { display: flex; flex-wrap: wrap; gap: 10px; } -#operation-status, #file-operation-status, #quick-operation-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid #263b57; border-radius: 10px; background: #07101d; color: #c6d5e8; white-space: pre-wrap; } -.yaml-editor { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9rem; line-height: 1.45; } +label span { color: var(--muted); font-weight: 400; } +.operation-actions { display: flex; flex-wrap: wrap; gap: 9px; } +#operation-status, #file-operation-status, #quick-operation-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: #050c16; color: #c6d5e8; white-space: pre-wrap; } +#file-operation-status { margin: 0; border: 0; border-top: 1px solid var(--border); border-radius: 0; } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin: 16px 0; } -.form-grid label { color: #aabbd0; } +.form-grid label { color: var(--muted); } .form-grid input { display: block; width: 100%; margin-top: 7px; } -select { width: 100%; margin-bottom: 16px; } -fieldset { margin: 16px 0; padding: 16px; border: 1px solid #38506f; border-radius: 10px; } +fieldset { margin: 16px 0; padding: 16px; border: 1px solid var(--border-strong); border-radius: 9px; } fieldset .check-row { margin: 10px 0; } -#detected-plugins { color: #aabbd0; } +#detected-plugins { color: var(--muted); } .quick-fields[hidden] { display: none; } -code { color: #8ef0b1; } -.node h3 { margin-bottom: 6px; } -.node p { color: #aabbd0; } +code { color: var(--green); } .pagination { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 20px; } -.pagination output { min-width: 80px; color: #aabbd0; text-align: center; } +.pagination output { min-width: 80px; color: var(--muted); text-align: center; } ul { margin: 0; padding: 0; list-style: none; } -li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-top: 1px solid #263b57; } -li span { color: #aabbd0; font-size: .9rem; } -@media (max-width: 640px) { header, .auth-row { align-items: stretch; flex-direction: column; } .status { align-self: flex-start; } .auth-actions button, .operation-actions button { width: 100%; } } +li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-top: 1px solid var(--border); } +li span { color: var(--muted); font-size: .9rem; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@media (max-width: 920px) { + .topbar { position: static; align-items: flex-start; } + .topbar-actions { flex-wrap: wrap; } + .server-picker { order: 3; width: 100%; grid-template-columns: auto 1fr; } + .metrics, .settings-grid { grid-template-columns: repeat(2, 1fr); } + .roadmap-card { grid-template-columns: 1fr; } + .roadmap-card > div { border-right: 0; border-bottom: 1px solid var(--border); } + .topology-row { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .topbar { flex-direction: column; padding: 14px 18px; } + .topbar-actions { width: 100%; justify-content: flex-start; } + .server-picker { grid-template-columns: 1fr; } + main { width: min(100% - 28px, 1240px); padding-top: 30px; } + .welcome h1 { font-size: 2.35rem; } + .page-heading, .section-title, .auth-row, .editor-footer { align-items: stretch; flex-direction: column; } + .page-heading > button, .auth-row button { width: 100%; } + .metrics, .settings-grid { grid-template-columns: 1fr; } + .metrics article { min-height: 92px; } + .tabs { margin: 0 -14px; padding: 0 14px; } + .subtabs button { padding-inline: 8px; } + .editor-toolbar { grid-template-columns: 1fr; } + .editor-toolbar > span { justify-self: start; } + .yaml-editor { min-height: 420px; padding: 16px; } + .operation-actions button { width: 100%; } +} From e6dc81df2c4601a611992b8839c8e014309e9f43 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:04:32 -0600 Subject: [PATCH 03/26] Redesign hosted WebUI: app.js --- src/main/resources/web/app.js | 379 +++++++++++++++++++++++++++++++--- 1 file changed, 354 insertions(+), 25 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index e76723a0..4b08f2e9 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -13,6 +13,24 @@ const passwordInput = document.querySelector('#password'); const loginButton = form.querySelector('button[type="submit"]'); const logout = document.querySelector('#logout'); const message = document.querySelector('#message'); +const welcome = document.querySelector('#welcome'); +const appShell = document.querySelector('#app-shell'); +const serverPickerLabel = document.querySelector('#server-picker-label'); +const serverPicker = document.querySelector('#server-picker'); +const tabButtons = [...document.querySelectorAll('[data-tab]')]; +const tabPanels = [...document.querySelectorAll('[data-panel]')]; +const configViewButtons = [...document.querySelectorAll('[data-config-view]')]; +const configViewPanels = [...document.querySelectorAll('[data-config-panel]')]; +const metricNodes = document.querySelector('#metric-nodes'); +const metricOnline = document.querySelector('#metric-online'); +const metricBackends = document.querySelector('#metric-backends'); +const metricIssues = document.querySelector('#metric-issues'); +const selectedServerName = document.querySelector('#selected-server-name'); +const selectedServerState = document.querySelector('#selected-server-state'); +const selectedServerSummary = document.querySelector('#selected-server-summary'); +const selectedServerCapabilities = document.querySelector('#selected-server-capabilities'); +const configurationContext = document.querySelector('#configuration-context'); +const topology = document.querySelector('#topology'); const nodes = document.querySelector('#nodes'); const refresh = document.querySelector('#refresh'); const previousPage = document.querySelector('#previous-page'); @@ -27,6 +45,7 @@ const operationStatus = document.querySelector('#operation-status'); const configurationForm = document.querySelector('#configuration-form'); const configurationFile = document.querySelector('#configuration-file'); const configurationContent = document.querySelector('#configuration-content'); +const editorPosition = document.querySelector('#editor-position'); const readFileConfiguration = document.querySelector('#read-file-configuration'); const previewFileConfiguration = document.querySelector('#preview-file-configuration'); const applyFileConfiguration = document.querySelector('#apply-file-configuration'); @@ -70,6 +89,9 @@ let authenticated = false; let csrfToken = ''; let pageOffset = 0; let selectedNodes = new Set(); +let selectedServerId = ''; +let visibleNodeItems = []; +let nodeIndex = new Map(); let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; @@ -134,24 +156,108 @@ function applyAuthenticatedSession(body) { configurationContent.value = ''; inputGeneration++; logout.hidden = false; + authCard.hidden = true; + welcome.hidden = true; + appShell.hidden = false; + serverPickerLabel.hidden = false; enrollmentCard.hidden = false; pageOffset = 0; + setActiveTab(tabFromHash()); +} + +function isProxy(node) { + return ['VELOCITY', 'BUNGEECORD'].includes(String(node.platform).toUpperCase()) || + node.acceptedCapabilities.includes('config.proxy-routing.v1'); +} + +function isBackend(node) { + return !isProxy(node); +} + +function roleLabel(node) { + return isProxy(node) ? 'Proxy' : 'Backend'; +} + +function platformLabel(platform) { + const normalized = String(platform || '').toUpperCase(); + if (normalized === 'BUKKIT') return 'Bukkit'; + if (normalized === 'BUNGEECORD') return 'BungeeCord'; + if (normalized === 'VELOCITY') return 'Velocity'; + return platform || 'Unspecified platform'; +} + +function friendlyCapability(capability) { + return ({ + 'config.files.v1': 'Full configuration', + 'config.quick-setup.v1': 'Quick Setup', + 'config.proxy-routing.v1': 'Proxy routing' + })[capability]; +} + +function managedCapabilities(node) { + return node.acceptedCapabilities.map(friendlyCapability).filter(Boolean); +} + +function proxyReportsFor(backendId) { + return visibleNodeItems.filter(isProxy).filter(proxy => + (Array.isArray(proxy.backends) ? proxy.backends : []).some(backend => backend.backendId === backendId)); } function backendCard(backend) { const item = document.createElement('li'); - const state = backend.presenceKnown ? (backend.available ? 'online' : 'offline') : 'unknown'; const title = text(document.createElement('strong'), backend.displayName); - const details = text(document.createElement('span'), - `${state} · ${backend.presenceKnown ? backend.playerCount : '–'} players · ${backend.backendId}`); + const details = document.createElement('div'); + details.className = 'backend-state'; + const registered = nodeIndex.get(backend.backendId); + details.append(text(document.createElement('span'), registered + ? `Enrolled in Control · ${registered.online ? 'Control connected' : 'Control disconnected'}` + : 'Not enrolled in Control')); + if (backend.presenceKnown) { + details.append(text(document.createElement('span'), backend.available + ? `Minecraft reachable · ${backend.playerCount} ${backend.playerCount === 1 ? 'player' : 'players'}` + : 'Minecraft unavailable')); + } else { + details.append(text(document.createElement('span'), 'Presence not available')); + } + details.append(text(document.createElement('span'), backend.backendId)); item.append(title, details); return item; } function nodeCard(node) { const article = document.createElement('article'); - article.className = 'node'; + article.className = `node${selectedServerId === node.nodeId ? ' selected' : ''}`; + article.dataset.nodeId = node.nodeId; + const header = document.createElement('div'); + header.className = 'node-header'; + const identity = document.createElement('div'); const title = text(document.createElement('h3'), node.displayName); + identity.append(title, text(document.createElement('span'), node.nodeId)); + identity.lastElementChild.className = 'node-id'; + const state = text(document.createElement('span'), node.online ? 'Control connected' : 'Control disconnected'); + state.className = `pill ${node.online ? 'online' : 'offline'}`; + header.append(identity, state); + + const meta = document.createElement('div'); + meta.className = 'node-meta'; + [roleLabel(node), platformLabel(node.platform), `VotingPlugin ${node.pluginVersion}`, + ...managedCapabilities(node)].forEach(value => { + const pill = text(document.createElement('span'), value); + pill.className = 'pill neutral'; + meta.append(pill); + }); + + const plugins = Array.isArray(node.detectedPlugins) ? node.detectedPlugins : []; + let detail; + if (isBackend(node)) { + detail = text(document.createElement('p'), plugins.length + ? `Detected plugins: ${plugins.join(', ')}` : 'Plugin inventory is not available for this backend.'); + } else { + const backends = Array.isArray(node.backends) ? node.backends : []; + detail = text(document.createElement('p'), `${backends.length} configured ${backends.length === 1 ? 'backend' : 'backends'} reported.`); + } + detail.className = 'node-detail'; + const selector = document.createElement('label'); selector.className = 'node-select'; const checkbox = document.createElement('input'); @@ -169,23 +275,179 @@ function nodeCard(node) { updatePluginSuggestions(); updateConfigurationButtons(); }); - selector.append(checkbox, title); - const meta = text(document.createElement('p'), - `${node.platform} · VotingPlugin ${node.pluginVersion} · ${node.online ? 'online' : 'offline'} · ${node.acceptedCapabilities.filter(value => value.startsWith('config.')).join(', ') || 'discovery only'}`); - const plugins = Array.isArray(node.detectedPlugins) ? node.detectedPlugins : []; - const pluginMeta = text(document.createElement('p'), plugins.length - ? `Detected plugins: ${plugins.join(', ')}` : 'No Bukkit plugin inventory reported.'); + selector.append(checkbox, document.createTextNode('Include in configuration changes')); + const list = document.createElement('ul'); - const backends = Array.isArray(node.backends) ? node.backends : []; - if (backends.length === 0) { - list.append(text(document.createElement('li'), 'No backends reported.')); + list.className = 'node-backends'; + if (isProxy(node)) { + const backends = Array.isArray(node.backends) ? node.backends : []; + if (backends.length === 0) { + list.append(text(document.createElement('li'), 'No configured backends reported by this proxy.')); + } else { + backends.forEach(backend => list.append(backendCard(backend))); + } } else { - backends.forEach(backend => list.append(backendCard(backend))); + const proxies = proxyReportsFor(node.nodeId); + list.append(text(document.createElement('li'), proxies.length + ? `Reported by ${proxies.map(proxy => proxy.displayName).join(', ')}.` + : 'No connected proxy reports this backend ID.')); } - article.append(selector, meta, pluginMeta, list); + article.append(header, meta, detail, list, selector); return article; } +function tabFromHash() { + const requested = window.location.hash.replace(/^#/, ''); + return tabPanels.some(panel => panel.dataset.panel === requested) ? requested : 'overview'; +} + +function setActiveTab(tab, updateHash = false) { + if (!tabPanels.some(panel => panel.dataset.panel === tab)) tab = 'overview'; + tabButtons.forEach(button => button.setAttribute('aria-selected', String(button.dataset.tab === tab))); + tabPanels.forEach(panel => { panel.hidden = panel.dataset.panel !== tab; }); + if (updateHash && window.location.hash !== `#${tab}`) window.history.replaceState(null, '', `#${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; }); +} + +function chooseDefaultServer(items) { + return items.find(node => isBackend(node) && node.online) || items.find(node => isBackend(node)) || + items.find(node => node.online) || items[0] || null; +} + +function renderServerPicker() { + const previousValue = selectedServerId; + const ordered = [...visibleNodeItems].sort((left, right) => { + const roleOrder = Number(isProxy(left)) - Number(isProxy(right)); + return roleOrder || left.displayName.localeCompare(right.displayName); + }); + const placeholder = text(document.createElement('option'), ordered.length ? 'Choose a server' : 'No servers registered'); + placeholder.value = ''; + serverPicker.replaceChildren(placeholder, ...ordered.map(node => { + const option = text(document.createElement('option'), + `${node.displayName} · ${roleLabel(node)} · ${node.online ? 'connected' : 'disconnected'}`); + option.value = node.nodeId; + return option; + })); + if (!nodeIndex.has(previousValue)) selectedServerId = chooseDefaultServer(ordered)?.nodeId || ''; + serverPicker.value = selectedServerId; +} + +function renderSelectedServer() { + const selected = nodeIndex.get(selectedServerId); + selectedServerCapabilities.replaceChildren(); + if (!selected) { + text(selectedServerName, 'Choose a VotingPlugin server'); + text(selectedServerState, 'No selection'); + selectedServerState.className = 'pill neutral'; + text(selectedServerSummary, 'Use the server picker above to keep configuration and setup actions focused on one node.'); + text(configurationContext, 'Choose a backend from the server picker to work with its VotingPlugin configuration.'); + return; + } + text(selectedServerName, selected.displayName); + text(selectedServerState, selected.online ? 'Control connected' : 'Control disconnected'); + selectedServerState.className = `pill ${selected.online ? 'online' : 'offline'}`; + const relationships = isBackend(selected) ? proxyReportsFor(selected.nodeId) : []; + const relationshipText = relationships.length + ? ` Reported by ${relationships.map(proxy => proxy.displayName).join(', ')}.` : ''; + text(selectedServerSummary, + `${roleLabel(selected)} · ${platformLabel(selected.platform)} · VotingPlugin ${selected.pluginVersion}.${relationshipText}`); + text(configurationContext, + `${selected.displayName} (${selected.nodeId}) · ${roleLabel(selected)} · ${selected.online ? 'Control connected' : 'Control disconnected'}`); + const capabilities = managedCapabilities(selected); + if (capabilities.length === 0) capabilities.push('Discovery only'); + capabilities.forEach(value => { + const pill = text(document.createElement('span'), value); + pill.className = 'pill'; + selectedServerCapabilities.append(pill); + }); +} + +function topologyLink(backend) { + const registered = nodeIndex.get(backend.backendId); + const link = document.createElement('span'); + link.className = `topology-link ${registered?.online ? 'online' : 'warning'}`; + link.append(text(document.createElement('strong'), backend.displayName)); + link.append(text(document.createElement('small'), registered + ? `Enrolled · Control ${registered.online ? 'connected' : 'disconnected'}` + : 'Not enrolled in Control')); + if (backend.presenceKnown) { + link.append(text(document.createElement('small'), backend.available + ? `Minecraft reachable · ${backend.playerCount} ${backend.playerCount === 1 ? 'player' : 'players'}` + : 'Minecraft unavailable')); + } else { + link.append(text(document.createElement('small'), 'Presence not available')); + } + return link; +} + +function renderTopology() { + const proxies = visibleNodeItems.filter(isProxy); + topology.replaceChildren(); + topology.classList.toggle('empty', proxies.length === 0); + if (proxies.length === 0) { + text(topology, 'No proxy nodes have registered with Control.'); + return; + } + proxies.forEach(proxy => { + const row = document.createElement('article'); + row.className = 'topology-row'; + const proxyIdentity = document.createElement('div'); + proxyIdentity.className = 'topology-proxy'; + proxyIdentity.append(text(document.createElement('strong'), proxy.displayName)); + proxyIdentity.append(text(document.createElement('small'), + `${platformLabel(proxy.platform)} proxy · Control ${proxy.online ? 'connected' : 'disconnected'}`)); + const backendList = document.createElement('div'); + backendList.className = 'topology-backends'; + const backends = Array.isArray(proxy.backends) ? proxy.backends : []; + if (backends.length === 0) { + backendList.append(text(document.createElement('small'), 'No configured backends reported by this proxy.')); + } else { + backends.forEach(backend => backendList.append(topologyLink(backend))); + } + row.append(proxyIdentity, backendList); + topology.append(row); + }); +} + +function renderMetrics() { + const backendIds = new Set(visibleNodeItems.filter(isProxy).flatMap(node => + (Array.isArray(node.backends) ? node.backends : []).map(backend => backend.backendId))); + const unenrolledBackends = [...backendIds].filter(backendId => !nodeIndex.has(backendId)).length; + text(metricNodes, visibleNodeItems.length); + text(metricOnline, visibleNodeItems.filter(node => node.online).length); + text(metricBackends, backendIds.size); + text(metricIssues, visibleNodeItems.filter(node => !node.online).length + unenrolledBackends); +} + +function renderNodeViews() { + nodes.replaceChildren(); + nodes.classList.toggle('empty', visibleNodeItems.length === 0); + if (visibleNodeItems.length === 0) { + text(nodes, pageOffset === 0 ? 'No VotingPlugin nodes have registered yet.' : 'No nodes on this page.'); + } else { + visibleNodeItems.forEach(node => nodes.append(nodeCard(node))); + } + renderMetrics(); + renderTopology(); + renderSelectedServer(); +} + +function selectPrimaryServer(nodeId) { + if (nodeId && !nodeIndex.has(nodeId)) return; + selectedServerId = nodeId; + serverPicker.value = nodeId; + selectedNodes.clear(); + if (nodeId) selectedNodes.add(nodeId); + clearApprovals(); + updatePluginSuggestions(); + renderNodeViews(); +} + function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) { const routingReady = authenticated && targets('config.proxy-routing.v1').length > 0 && !busy; const fileReady = authenticated && targets('config.files.v1').length > 0 && !busy; @@ -212,6 +474,36 @@ function clearApprovals() { updateConfigurationButtons(); } +function updateEditorPosition() { + const beforeCursor = configurationContent.value.slice(0, configurationContent.selectionStart); + const lines = beforeCursor.split('\n'); + text(editorPosition, `Line ${lines.length}, Column ${lines.at(-1).length + 1}`); +} + +function handleEditorKeydown(event) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + if (!previewFileConfiguration.disabled) previewFileConfiguration.click(); + return; + } + if (event.key !== 'Tab') return; + event.preventDefault(); + const start = configurationContent.selectionStart; + const end = configurationContent.selectionEnd; + if (event.shiftKey) { + const lineStart = configurationContent.value.lastIndexOf('\n', start - 1) + 1; + const removable = configurationContent.value.slice(lineStart, lineStart + 2).match(/^ {1,2}/)?.[0].length || 0; + if (removable > 0) { + configurationContent.setRangeText('', lineStart, lineStart + removable, 'preserve'); + configurationContent.setSelectionRange(Math.max(lineStart, start - removable), Math.max(lineStart, end - removable)); + } + } else { + configurationContent.setRangeText(' ', start, end, 'end'); + } + configurationContent.dispatchEvent(new Event('input', {bubbles: true})); + updateEditorPosition(); +} + function updateQuickFields() { document.querySelectorAll('.quick-fields').forEach(group => { group.hidden = !group.dataset.presets.split(' ').includes(quickPreset.value); @@ -273,11 +565,18 @@ function discardAuthenticationState(reason) { approvedQuickPreview = null; inputGeneration++; logout.hidden = true; + appShell.hidden = true; + serverPickerLabel.hidden = true; + welcome.hidden = false; + authCard.hidden = setupRequired; enrollmentCard.hidden = true; enrollmentCredential.value = ''; enrollmentList.replaceChildren(text(document.createElement('li'), 'Authenticate to manage enrollments.')); text(enrollmentMessage, ''); selectedNodes.clear(); + selectedServerId = ''; + visibleNodeItems = []; + nodeIndex.clear(); nodeCapabilities.clear(); nodePlugins.clear(); configurationForm.reset(); @@ -292,6 +591,11 @@ function discardAuthenticationState(reason) { nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); + serverPicker.replaceChildren(text(document.createElement('option'), 'Choose a server')); + serverPicker.firstElementChild.value = ''; + renderMetrics(); + renderTopology(); + renderSelectedServer(); text(message, reason); updateConfigurationButtons(); } @@ -397,13 +701,9 @@ async function loadNodes() { text(message, 'Loading…'); try { const body = await authorized(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`); - nodes.replaceChildren(); - nodes.classList.toggle('empty', body.items.length === 0); - if (body.items.length === 0) { - text(nodes, pageOffset === 0 ? 'No proxies have registered yet.' : 'No proxies on this page.'); - } else { - body.items.forEach(node => nodes.append(nodeCard(node))); - } + visibleNodeItems = body.items; + nodeIndex = new Map(body.items.map(node => [node.nodeId, node])); + const needsDefaultTargets = !selectedServerId || !nodeIndex.has(selectedServerId); const previousCapabilities = nodeCapabilities; nodeCapabilities = new Map(body.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); nodePlugins = new Map(body.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) @@ -440,17 +740,23 @@ async function loadNodes() { approvedFilePreview = null; approvedQuickPreview = null; inputGeneration++; - text(operationStatus, 'The selected proxies changed during refresh. Preview again before apply.'); + text(operationStatus, 'The selected nodes changed during refresh. Preview again before apply.'); } selectedNodes = filteredSelection; + renderServerPicker(); + if (needsDefaultTargets && selectedServerId && + nodeCapabilities.get(selectedServerId)?.some(value => value.startsWith('config.'))) { + selectedNodes.add(selectedServerId); + } + renderNodeViews(); updatePluginSuggestions(); updateConfigurationButtons(); const first = body.items.length === 0 ? 0 : pageOffset + 1; const last = pageOffset + body.items.length; const backendLimit = body.backendItemsTruncated ? ` Backend summaries are limited to ${body.backendItemsReturned} entries on this page.` : ''; - text(message, body.items.length === 0 ? 'No proxies on this page.' - : `Showing proxies ${first}–${last}.${backendLimit}`); + text(message, body.items.length === 0 ? 'No nodes on this page.' + : `Showing nodes ${first}–${last}.${backendLimit}`); text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`); previousPage.disabled = pageOffset === 0; nextPage.disabled = body.items.length < PAGE_SIZE; @@ -651,6 +957,7 @@ readFileConfiguration.addEventListener('click', async () => { if (contentResult && authenticated && readAuthenticationGeneration === authenticationGeneration && readInputGeneration === inputGeneration && selectedFile === configurationFile.value) { configurationContent.value = contentResult.configuration.content; + updateEditorPosition(); text(fileOperationStatus, operationSummary(operation)); inputGeneration++; updateConfigurationButtons(); @@ -746,12 +1053,32 @@ applyQuickSetup.addEventListener('click', async () => { quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, quickPartyAll, quickPartyOnline].forEach(field => field.addEventListener('input', clearApprovals)); +configurationContent.addEventListener('input', updateEditorPosition); +configurationContent.addEventListener('click', updateEditorPosition); +configurationContent.addEventListener('keyup', updateEditorPosition); +configurationContent.addEventListener('keydown', handleEditorKeydown); configurationFile.addEventListener('input', () => { configurationContent.value = ''; + updateEditorPosition(); text(fileOperationStatus, 'Read the selected file before previewing changes.'); clearApprovals(); }); quickPreset.addEventListener('input', () => { updateQuickFields(); clearApprovals(); }); +serverPicker.addEventListener('change', () => selectPrimaryServer(serverPicker.value)); +tabButtons.forEach(button => button.addEventListener('click', () => setActiveTab(button.dataset.tab, true))); +configViewButtons.forEach(button => button.addEventListener('click', () => setConfigView(button.dataset.configView))); +document.querySelectorAll('[data-open-tab]').forEach(button => button.addEventListener('click', () => { + setActiveTab(button.dataset.openTab, true); +})); +document.querySelectorAll('[data-open-config-view]').forEach(button => button.addEventListener('click', () => { + setActiveTab('configurations', true); + setConfigView(button.dataset.openConfigView); + if (button.dataset.file && configurationFile.value !== button.dataset.file) { + configurationFile.value = button.dataset.file; + configurationFile.dispatchEvent(new Event('input')); + } +})); +window.addEventListener('hashchange', () => setActiveTab(tabFromHash())); refresh.addEventListener('click', loadNodes); previousPage.addEventListener('click', () => { pageOffset = Math.max(0, pageOffset - PAGE_SIZE); @@ -763,6 +1090,8 @@ nextPage.addEventListener('click', () => { }); async function initialize() { await loadHealth(); + setActiveTab(tabFromHash()); + setConfigView('easy'); updateQuickFields(); updatePluginSuggestions(); if (!await loadSetupState()) { From f05e7391369c1736796886fe1061ca6bfa98365d Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:04:34 -0600 Subject: [PATCH 04/26] Redesign hosted WebUI: ControlHttpServerTest.java --- .../control/http/ControlHttpServerTest.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 95374a84..a3454842 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -60,6 +60,11 @@ class ControlHttpServerTest { HttpResponse web = get("/", null); assertEquals(200, web.statusCode()); assertTrue(web.body().contains("VotingPlugin Control")); + assertTrue(web.body().contains("data-tab=\"overview\"")); + assertTrue(web.body().contains("data-tab=\"network\"")); + assertTrue(web.body().contains("id=\"server-picker\"")); + assertTrue(web.body().contains("Full YAML")); + assertTrue(web.body().contains("Comments not yet preserved")); assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); @@ -93,7 +98,16 @@ class ControlHttpServerTest { assertTrue(script.body().contains("quickCommandSuggestions.replaceChildren();")); assertTrue(script.body().contains("Sign out could not be confirmed")); assertTrue(script.body().contains("result.success && result.configuration")); - assertEquals(200, get("/app.css", null).statusCode()); + assertTrue(script.body().contains("Not enrolled in Control")); + assertTrue(script.body().contains("Presence not available")); + assertTrue(script.body().contains("No connected proxy reports this backend ID")); + assertTrue(script.body().contains("'config.files.v1': 'Full configuration'")); + assertTrue(script.body().contains("handleEditorKeydown")); + assertFalse(script.body().contains("'No backends reported.'")); + assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); + HttpResponse stylesheet = get("/app.css", null); + assertEquals(200, stylesheet.statusCode()); + assertTrue(stylesheet.body().contains(".tabs")); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); HttpResponse health = get("/api/v1/health", null); assertEquals(200, health.statusCode()); From a8603129eeb357b881940a3831d1428885c53358 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:09:10 -0600 Subject: [PATCH 05/26] Negotiate comment-preserving edits: InMemoryNodeRegistry.java --- .../votingplugin/control/domain/InMemoryNodeRegistry.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4c8b76ef..6537ae8e 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -26,7 +26,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.QUICK_SETUP_CAPABILITY); + ConfigurationOperations.QUICK_SETUP_CAPABILITY, "config.file-comments.v1"); private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT"); From 3415501f29043c7fec581e5c6627a91241dc85ca Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:09:12 -0600 Subject: [PATCH 06/26] Negotiate comment-preserving edits: index.html --- src/main/resources/web/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 030f0e5a..7c8ea762 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -192,7 +192,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.

- Comments not yet preserved + Comment support unknown
From d04a4c5724bb463db374857017b82a192ffa2684 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:09:14 -0600 Subject: [PATCH 07/26] Negotiate comment-preserving edits: app.js --- src/main/resources/web/app.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 4b08f2e9..ed7fb0db 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -30,6 +30,7 @@ const selectedServerState = document.querySelector('#selected-server-state'); const selectedServerSummary = document.querySelector('#selected-server-summary'); const selectedServerCapabilities = document.querySelector('#selected-server-capabilities'); const configurationContext = document.querySelector('#configuration-context'); +const commentPreservationState = document.querySelector('#comment-preservation-state'); const topology = document.querySelector('#topology'); const nodes = document.querySelector('#nodes'); const refresh = document.querySelector('#refresh'); @@ -189,6 +190,7 @@ function platformLabel(platform) { function friendlyCapability(capability) { return ({ 'config.files.v1': 'Full configuration', + 'config.file-comments.v1': 'Comments preserved', 'config.quick-setup.v1': 'Quick Setup', 'config.proxy-routing.v1': 'Proxy routing' })[capability]; @@ -346,6 +348,8 @@ function renderSelectedServer() { selectedServerState.className = 'pill neutral'; text(selectedServerSummary, 'Use the server picker above to keep configuration and setup actions focused on one node.'); text(configurationContext, 'Choose a backend from the server picker to work with its VotingPlugin configuration.'); + text(commentPreservationState, 'Comment support unknown'); + commentPreservationState.className = 'pill warning'; return; } text(selectedServerName, selected.displayName); @@ -358,6 +362,9 @@ 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 preservesComments = selected.acceptedCapabilities.includes('config.file-comments.v1'); + text(commentPreservationState, preservesComments ? 'Comments preserved' : 'Comments not guaranteed'); + commentPreservationState.className = `pill ${preservesComments ? 'online' : 'warning'}`; const capabilities = managedCapabilities(selected); if (capabilities.length === 0) capabilities.push('Discovery only'); capabilities.forEach(value => { From 4931392c6604de2237d5615a5510084dba177a32 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:09:16 -0600 Subject: [PATCH 08/26] Negotiate comment-preserving edits: InMemoryNodeRegistryTest.java --- .../control/domain/InMemoryNodeRegistryTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 3ffddacb..8ce5f94c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -35,6 +35,14 @@ class InMemoryNodeRegistryTest { () -> result.node().advertisedCapabilities().add("changed")); } + @Test void registrationNegotiatesOptionalCommentPreservingFileSupport() { + Set capabilities = Set.of("config.files.v1", "config.file-comments.v1"); + + var result = registry.register(registration("backend-lobby", session, capabilities, Set.of("config.files.v1"))); + + assertEquals(capabilities, result.node().acceptedCapabilities()); + } + @Test void heartbeatReplacesCapabilitiesAndRejectsUnavailableRequiredCapability() { registry.register(registration("proxy-a", session, Set.of("discovery.read"), Set.of())); var updated = registry.heartbeat("proxy-a", new Heartbeat(session, 1, Set.of("presence.snapshot"), Set.of())); From 6f1639d09ae66aad3c68b7c8f70f7bab84fb2fc7 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:09:18 -0600 Subject: [PATCH 09/26] Negotiate comment-preserving edits: ControlHttpServerTest.java --- .../votingplugin/control/http/ControlHttpServerTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a3454842..b3acce6c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -64,7 +64,7 @@ class ControlHttpServerTest { assertTrue(web.body().contains("data-tab=\"network\"")); assertTrue(web.body().contains("id=\"server-picker\"")); assertTrue(web.body().contains("Full YAML")); - assertTrue(web.body().contains("Comments not yet preserved")); + assertTrue(web.body().contains("Comment support unknown")); assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); @@ -102,6 +102,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Presence not available")); assertTrue(script.body().contains("No connected proxy reports this backend ID")); assertTrue(script.body().contains("'config.files.v1': 'Full configuration'")); + assertTrue(script.body().contains("'config.file-comments.v1': 'Comments preserved'")); assertTrue(script.body().contains("handleEditorKeydown")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); From 5473027a560e7abdf803072ada5f01f0cf961159 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:15:56 -0600 Subject: [PATCH 10/26] Address Codex workspace review findings --- src/main/resources/web/app.js | 394 +++++++++++++++++- src/main/resources/web/index.html | 75 +++- .../control/http/ControlHttpServerTest.java | 18 + 3 files changed, 463 insertions(+), 24 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index ed7fb0db..fd213df0 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -77,6 +77,21 @@ const quickPartyOnline = document.querySelector('#quick-party-online'); const previewQuickSetup = document.querySelector('#preview-quick-setup'); const applyQuickSetup = document.querySelector('#apply-quick-setup'); const quickOperationStatus = document.querySelector('#quick-operation-status'); +const voteSitesSource = document.querySelector('#vote-sites-source'); +const voteSitesTargets = document.querySelector('#vote-sites-targets'); +const voteSitesSyncCapability = document.querySelector('#vote-sites-sync-capability'); +const previewVoteSitesSync = document.querySelector('#preview-vote-sites-sync'); +const applyVoteSitesSync = document.querySelector('#apply-vote-sites-sync'); +const voteSitesSyncStatus = document.querySelector('#vote-sites-sync-status'); +const transportTestProxy = document.querySelector('#transport-test-proxy'); +const transportTestBackend = document.querySelector('#transport-test-backend'); +const transportTestCapability = document.querySelector('#transport-test-capability'); +const runTransportTest = document.querySelector('#run-transport-test'); +const transportTestStatus = document.querySelector('#transport-test-status'); +const proxyMethodProxy = document.querySelector('#proxy-method-proxy'); +const proxyMethodCapability = document.querySelector('#proxy-method-capability'); +const proxyMethodButtons = [...document.querySelectorAll('[data-proxy-method]')]; +const proxyMethodStatus = document.querySelector('#proxy-method-status'); const enrollmentCard = document.querySelector('#enrollment-card'); const enrollmentForm = document.querySelector('#enrollment-form'); const enrollmentSubmit = enrollmentForm.querySelector('button[type="submit"]'); @@ -92,10 +107,20 @@ let pageOffset = 0; let selectedNodes = new Set(); let selectedServerId = ''; let visibleNodeItems = []; +let allNodeItems = []; let nodeIndex = new Map(); +let enrollmentIds = new Set(); let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; +let approvedVoteSitesPreview = null; +let voteSitesSourceId = ''; +let voteSitesTargetIds = new Set(); +let voteSitesTargetsInitialized = false; +let transportTestProxyId = ''; +let transportTestBackendId = ''; +let proxyMethodProxyId = ''; +let backendTopologyTruncated = false; let nodeCapabilities = new Map(); let nodePlugins = new Map(); let inputGeneration = 0; @@ -153,7 +178,14 @@ function applyAuthenticatedSession(body) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + approvedVoteSitesPreview = null; selectedNodes.clear(); + voteSitesSourceId = ''; + voteSitesTargetIds.clear(); + voteSitesTargetsInitialized = false; + transportTestProxyId = ''; + transportTestBackendId = ''; + proxyMethodProxyId = ''; configurationContent.value = ''; inputGeneration++; logout.hidden = false; @@ -191,6 +223,9 @@ function friendlyCapability(capability) { return ({ 'config.files.v1': 'Full configuration', 'config.file-comments.v1': 'Comments preserved', + 'config.vote-sites-sync.v1': 'VoteSites sync', + 'config.transport-test.v1': 'Communication test', + 'config.proxy-method.v1': 'Proxy method', 'config.quick-setup.v1': 'Quick Setup', 'config.proxy-routing.v1': 'Proxy routing' })[capability]; @@ -201,7 +236,7 @@ function managedCapabilities(node) { } function proxyReportsFor(backendId) { - return visibleNodeItems.filter(isProxy).filter(proxy => + return allNodeItems.filter(isProxy).filter(proxy => (Array.isArray(proxy.backends) ? proxy.backends : []).some(backend => backend.backendId === backendId)); } @@ -211,8 +246,8 @@ function backendCard(backend) { const details = document.createElement('div'); details.className = 'backend-state'; const registered = nodeIndex.get(backend.backendId); - details.append(text(document.createElement('span'), registered - ? `Enrolled in Control · ${registered.online ? 'Control connected' : 'Control disconnected'}` + details.append(text(document.createElement('span'), enrollmentIds.has(backend.backendId) + ? `Enrolled in Control · ${registered ? (registered.online ? 'Control connected' : 'Control disconnected') : 'not registered'}` : 'Not enrolled in Control')); if (backend.presenceKnown) { details.append(text(document.createElement('span'), backend.available @@ -323,7 +358,7 @@ function chooseDefaultServer(items) { function renderServerPicker() { const previousValue = selectedServerId; - const ordered = [...visibleNodeItems].sort((left, right) => { + const ordered = [...allNodeItems].sort((left, right) => { const roleOrder = Number(isProxy(left)) - Number(isProxy(right)); return roleOrder || left.displayName.localeCompare(right.displayName); }); @@ -379,8 +414,8 @@ function topologyLink(backend) { const link = document.createElement('span'); link.className = `topology-link ${registered?.online ? 'online' : 'warning'}`; link.append(text(document.createElement('strong'), backend.displayName)); - link.append(text(document.createElement('small'), registered - ? `Enrolled · Control ${registered.online ? 'connected' : 'disconnected'}` + link.append(text(document.createElement('small'), enrollmentIds.has(backend.backendId) + ? `Enrolled · Control ${registered ? (registered.online ? 'connected' : 'disconnected') : 'not registered'}` : 'Not enrolled in Control')); if (backend.presenceKnown) { link.append(text(document.createElement('small'), backend.available @@ -393,7 +428,7 @@ function topologyLink(backend) { } function renderTopology() { - const proxies = visibleNodeItems.filter(isProxy); + const proxies = allNodeItems.filter(isProxy); topology.replaceChildren(); topology.classList.toggle('empty', proxies.length === 0); if (proxies.length === 0) { @@ -422,13 +457,170 @@ function renderTopology() { } function renderMetrics() { - const backendIds = new Set(visibleNodeItems.filter(isProxy).flatMap(node => + const backendIds = new Set(allNodeItems.filter(isProxy).flatMap(node => (Array.isArray(node.backends) ? node.backends : []).map(backend => backend.backendId))); - const unenrolledBackends = [...backendIds].filter(backendId => !nodeIndex.has(backendId)).length; - text(metricNodes, visibleNodeItems.length); - text(metricOnline, visibleNodeItems.filter(node => node.online).length); + const unenrolledBackends = [...backendIds].filter(backendId => !enrollmentIds.has(backendId)).length; + text(metricNodes, allNodeItems.length); + text(metricOnline, allNodeItems.filter(node => node.online).length); text(metricBackends, backendIds.size); - text(metricIssues, visibleNodeItems.filter(node => !node.online).length + unenrolledBackends); + text(metricIssues, allNodeItems.filter(node => !node.online).length + unenrolledBackends); +} + +function syncSourceCandidates() { + return allNodeItems.filter(node => isBackend(node) && node.online && + node.acceptedCapabilities.includes('config.files.v1') && + node.acceptedCapabilities.includes('config.file-comments.v1')); +} + +function syncTargetCandidates() { + return allNodeItems.filter(node => isBackend(node) && node.online && + node.acceptedCapabilities.includes('config.vote-sites-sync.v1')); +} + +function selectedVoteSitesTargets() { + const capable = new Set(syncTargetCandidates().map(node => node.nodeId)); + return [...voteSitesTargetIds].filter(nodeId => capable.has(nodeId) && nodeId !== voteSitesSourceId); +} + +function renderVoteSitesSync() { + const sources = syncSourceCandidates(); + const targetsAvailable = syncTargetCandidates(); + if (!sources.some(node => node.nodeId === voteSitesSourceId)) { + voteSitesSourceId = sources.find(node => node.nodeId === selectedServerId)?.nodeId || sources[0]?.nodeId || ''; + } + const targetIds = new Set(targetsAvailable.map(node => node.nodeId)); + const retainedTargets = new Set([...voteSitesTargetIds].filter(nodeId => + targetIds.has(nodeId) && nodeId !== voteSitesSourceId)); + if (retainedTargets.size !== voteSitesTargetIds.size) { + approvedVoteSitesPreview = null; + inputGeneration++; + text(voteSitesSyncStatus, 'A sync target became unavailable. Preview again before syncing.'); + } + voteSitesTargetIds = retainedTargets; + if (!voteSitesTargetsInitialized && sources.length > 0) { + voteSitesTargetIds = new Set(targetsAvailable.map(node => node.nodeId) + .filter(nodeId => nodeId !== voteSitesSourceId)); + voteSitesTargetsInitialized = true; + } + + const placeholder = text(document.createElement('option'), sources.length ? 'Choose a source' : 'No readable backends'); + placeholder.value = ''; + voteSitesSource.replaceChildren(placeholder, ...sources.map(node => { + const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); + option.value = node.nodeId; + return option; + })); + voteSitesSource.value = voteSitesSourceId; + + voteSitesTargets.replaceChildren(); + const choices = targetsAvailable.filter(node => node.nodeId !== voteSitesSourceId); + voteSitesTargets.classList.toggle('empty', choices.length === 0); + if (choices.length === 0) { + text(voteSitesTargets, targetsAvailable.length > 0 + ? 'Choose a different source or enroll another backend.' : 'No sync-capable target backends are connected.'); + } else { + choices.forEach(node => { + const label = document.createElement('label'); + label.className = 'target-option'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = voteSitesTargetIds.has(node.nodeId); + checkbox.addEventListener('change', () => { + if (checkbox.checked) voteSitesTargetIds.add(node.nodeId); else voteSitesTargetIds.delete(node.nodeId); + approvedVoteSitesPreview = null; + inputGeneration++; + text(voteSitesSyncStatus, 'Targets changed. Read the source and preview again before syncing.'); + updateConfigurationButtons(); + }); + label.append(checkbox, document.createTextNode(`${node.displayName} · ${node.nodeId}`)); + voteSitesTargets.append(label); + }); + } + const readyCount = targetsAvailable.length; + text(voteSitesSyncCapability, readyCount > 0 + ? `${readyCount} sync-capable ${readyCount === 1 ? 'backend' : 'backends'}` : 'Waiting for capable backends'); + voteSitesSyncCapability.className = `pill ${readyCount > 0 ? 'online' : 'neutral'}`; +} + +function transportTestProxies() { + return allNodeItems.filter(node => isProxy(node) && node.online && + node.acceptedCapabilities.includes('config.transport-test.v1')); +} + +function renderTransportTest() { + const proxies = transportTestProxies(); + if (!proxies.some(node => node.nodeId === transportTestProxyId)) { + transportTestProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; + transportTestBackendId = ''; + } + const proxy = proxies.find(node => node.nodeId === transportTestProxyId); + const backends = Array.isArray(proxy?.backends) ? proxy.backends : []; + if (!backends.some(backend => backend.backendId === transportTestBackendId)) { + transportTestBackendId = backends[0]?.backendId || ''; + } + + const proxyPlaceholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); + proxyPlaceholder.value = ''; + transportTestProxy.replaceChildren(proxyPlaceholder, ...proxies.map(node => { + const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); + option.value = node.nodeId; + return option; + })); + transportTestProxy.value = transportTestProxyId; + + const backendPlaceholder = text(document.createElement('option'), backends.length ? 'Choose a backend' : 'No backends reported'); + backendPlaceholder.value = ''; + transportTestBackend.replaceChildren(backendPlaceholder, ...backends.map(backend => { + const option = text(document.createElement('option'), `${backend.displayName} · ${backend.backendId}`); + option.value = backend.backendId; + return option; + })); + transportTestBackend.value = transportTestBackendId; + + text(transportTestCapability, proxies.length > 0 + ? `${proxies.length} test-capable ${proxies.length === 1 ? 'proxy' : 'proxies'}` : 'Waiting for a capable proxy'); + transportTestCapability.className = `pill ${proxies.length > 0 ? 'online' : 'neutral'}`; +} + +function proxyMethodCandidates() { + return allNodeItems.filter(node => isProxy(node) && node.online && + node.acceptedCapabilities.includes('config.proxy-method.v1')); +} + +function proxyMethodNetwork() { + const proxy = nodeIndex.get(proxyMethodProxyId); + const reported = Array.isArray(proxy?.backends) ? proxy.backends : []; + const backends = reported.map(backend => nodeIndex.get(backend.backendId)).filter(Boolean); + const unavailable = reported.filter(backend => { + const node = nodeIndex.get(backend.backendId); + return !node || !node.online || !node.acceptedCapabilities.includes('config.proxy-method.v1'); + }); + return {proxy, reported, backends, unavailable, topologyComplete: !backendTopologyTruncated, + nodeIds: proxy ? [proxy.nodeId, ...backends.map(node => node.nodeId)] : []}; +} + +function renderProxyMethod() { + const proxies = proxyMethodCandidates(); + if (!proxies.some(node => node.nodeId === proxyMethodProxyId)) { + proxyMethodProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; + } + const placeholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); + placeholder.value = ''; + proxyMethodProxy.replaceChildren(placeholder, ...proxies.map(node => { + const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); + option.value = node.nodeId; + return option; + })); + proxyMethodProxy.value = proxyMethodProxyId; + const network = proxyMethodNetwork(); + const ready = Boolean(network.proxy) && network.topologyComplete && network.reported.length > 0 && network.unavailable.length === 0; + const description = !network.proxy ? 'Waiting for a capable proxy' + : !network.topologyComplete ? 'Backend topology is truncated; switching disabled' + : network.reported.length === 0 ? 'No backends reported' + : network.unavailable.length > 0 ? `${network.unavailable.length} backends unavailable` + : `${network.nodeIds.length} nodes ready`; + text(proxyMethodCapability, description); + proxyMethodCapability.className = `pill ${ready ? 'online' : 'neutral'}`; } function renderNodeViews() { @@ -442,6 +634,9 @@ function renderNodeViews() { renderMetrics(); renderTopology(); renderSelectedServer(); + renderVoteSitesSync(); + renderTransportTest(); + renderProxyMethod(); } function selectPrimaryServer(nodeId) { @@ -459,6 +654,7 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) const routingReady = authenticated && targets('config.proxy-routing.v1').length > 0 && !busy; const fileReady = authenticated && targets('config.files.v1').length > 0 && !busy; const quickReady = authenticated && targets('config.quick-setup.v1').length > 0 && !busy; + const voteSitesReady = authenticated && voteSitesSourceId && selectedVoteSitesTargets().length > 0 && !busy; readConfiguration.disabled = !routingReady; previewConfiguration.disabled = !routingReady; applyConfiguration.disabled = !routingReady || !approvedPreview; @@ -467,6 +663,13 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) applyFileConfiguration.disabled = !fileReady || !approvedFilePreview; previewQuickSetup.disabled = !quickReady; applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; + previewVoteSitesSync.disabled = !voteSitesReady; + applyVoteSitesSync.disabled = !voteSitesReady || !approvedVoteSitesPreview; + runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy; + const methodNetwork = proxyMethodNetwork(); + const methodReady = authenticated && Boolean(methodNetwork.proxy) && methodNetwork.topologyComplete && methodNetwork.reported.length > 0 && + methodNetwork.unavailable.length === 0 && !busy; + proxyMethodButtons.forEach(button => { button.disabled = !methodReady; }); } function targets(capability) { @@ -477,6 +680,8 @@ function clearApprovals() { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + approvedVoteSitesPreview = null; + approvedVoteSitesPreview = null; inputGeneration++; updateConfigurationButtons(); } @@ -570,6 +775,7 @@ function discardAuthenticationState(reason) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + approvedVoteSitesPreview = null; inputGeneration++; logout.hidden = true; appShell.hidden = true; @@ -581,8 +787,17 @@ function discardAuthenticationState(reason) { enrollmentList.replaceChildren(text(document.createElement('li'), 'Authenticate to manage enrollments.')); text(enrollmentMessage, ''); selectedNodes.clear(); + voteSitesSourceId = ''; + voteSitesTargetIds.clear(); + voteSitesTargetsInitialized = false; + transportTestProxyId = ''; + transportTestBackendId = ''; + backendTopologyTruncated = false; + proxyMethodProxyId = ''; selectedServerId = ''; visibleNodeItems = []; + allNodeItems = []; + enrollmentIds.clear(); nodeIndex.clear(); nodeCapabilities.clear(); nodePlugins.clear(); @@ -595,6 +810,9 @@ function discardAuthenticationState(reason) { text(operationStatus, ''); text(fileOperationStatus, ''); text(quickOperationStatus, ''); + text(voteSitesSyncStatus, ''); + text(transportTestStatus, ''); + text(proxyMethodStatus, ''); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -658,6 +876,8 @@ async function loadEnrollments() { refreshEnrollments.disabled = true; try { const body = await authorized('/api/v1/enrollments'); + enrollmentIds = new Set(Array.isArray(body.nodeIds) ? body.nodeIds : []); + if (allNodeItems.length) renderNodeViews(); enrollmentList.replaceChildren(); if (!Array.isArray(body.nodeIds) || body.nodeIds.length === 0) { enrollmentList.append(text(document.createElement('li'), 'No nodes are enrolled yet.')); @@ -700,6 +920,17 @@ async function loadEnrollments() { } } +async function loadAllNodes() { + const items = []; + let truncated = false; + for (let offset = 0; ; offset += PAGE_SIZE) { + const page = await authorized(`/api/v1/nodes?offset=${offset}&limit=${PAGE_SIZE}`); + items.push(...page.items); + truncated ||= Boolean(page.backendItemsTruncated); + if (page.items.length < PAGE_SIZE) return {items, truncated}; + } +} + async function loadNodes() { if (!authenticated) return; refresh.disabled = true; @@ -707,13 +938,18 @@ async function loadNodes() { nextPage.disabled = true; text(message, 'Loading…'); try { - const body = await authorized(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`); + const [body, registry] = await Promise.all([ + authorized(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`), + loadAllNodes() + ]); visibleNodeItems = body.items; - nodeIndex = new Map(body.items.map(node => [node.nodeId, node])); + allNodeItems = registry.items; + backendTopologyTruncated = registry.truncated; + nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); const needsDefaultTargets = !selectedServerId || !nodeIndex.has(selectedServerId); const previousCapabilities = nodeCapabilities; - nodeCapabilities = new Map(body.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); - nodePlugins = new Map(body.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) + nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); + 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'].some(capability => @@ -732,14 +968,19 @@ async function loadNodes() { nodeCapabilities.get(node)?.includes('config.files.v1')); const invalidQuickApproval = approvedQuickPreview && !approvedQuickPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.quick-setup.v1')); - if (invalidRoutingApproval || invalidFileApproval || invalidQuickApproval) { + const invalidVoteSitesApproval = approvedVoteSitesPreview && + (!approvedVoteSitesPreview.nodeIds.every(node => + nodeCapabilities.get(node)?.includes('config.vote-sites-sync.v1')) || + !nodeCapabilities.get(approvedVoteSitesPreview.sourceId)?.includes('config.file-comments.v1')); + if (invalidRoutingApproval || invalidFileApproval || invalidQuickApproval || invalidVoteSitesApproval) { if (invalidRoutingApproval) approvedPreview = null; if (invalidFileApproval) approvedFilePreview = null; if (invalidQuickApproval) approvedQuickPreview = null; + if (invalidVoteSitesApproval) approvedVoteSitesPreview = null; inputGeneration++; text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); } - const visibleIds = new Set(body.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.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); if (filteredSelection.size !== selectedNodes.size) { @@ -1056,6 +1297,123 @@ applyQuickSetup.addEventListener('click', async () => { } catch (error) { text(quickOperationStatus, error.message); } }); +voteSitesSource.addEventListener('change', () => { + voteSitesSourceId = voteSitesSource.value; + voteSitesTargetIds.delete(voteSitesSourceId); + voteSitesTargetsInitialized = true; + approvedVoteSitesPreview = null; + inputGeneration++; + renderVoteSitesSync(); + text(voteSitesSyncStatus, 'Source changed. Read it and preview every target before syncing.'); + updateConfigurationButtons(); +}); + +previewVoteSitesSync.addEventListener('click', async () => { + approvedVoteSitesPreview = null; + const previewGeneration = inputGeneration; + const sourceId = voteSitesSourceId; + const nodeIds = selectedVoteSitesTargets(); + try { + const read = await startConfigurationOperation('/api/v1/configuration/read', { + nodeIds: [sourceId], configuration: {domain: 'file', fileName: 'VoteSites.yml'} + }, voteSitesSyncStatus); + const source = Object.values(read.results).find(result => + result.success && result.configuration?.content != null)?.configuration?.content; + if (source == null) throw new Error('The source backend did not return VoteSites.yml.'); + if (previewGeneration !== inputGeneration || sourceId !== voteSitesSourceId) { + text(voteSitesSyncStatus, 'The source or targets changed while reading. Preview again.'); + return; + } + const preview = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds, + configuration: {domain: 'quick-setup', preset: 'sync-vote-sites', options: {sourceContent: source}} + }, voteSitesSyncStatus); + if (preview.state === 'SUCCEEDED' && preview.approvalToken && previewGeneration === inputGeneration) { + approvedVoteSitesPreview = {operationId: preview.operationId, approvalToken: preview.approvalToken, + nodeIds, sourceId}; + updateConfigurationButtons(); + } else if (previewGeneration !== inputGeneration) { + text(voteSitesSyncStatus, 'The source or targets changed while previewing. Preview again before syncing.'); + } + } catch (error) { text(voteSitesSyncStatus, error.message); } +}); + +applyVoteSitesSync.addEventListener('click', async () => { + if (!approvedVoteSitesPreview || !window.confirm( + 'Sync the previewed VoteSites definitions to every selected backend? Target rewards and target-only sites are preserved.')) return; + const approval = approvedVoteSitesPreview; + approvedVoteSitesPreview = null; + inputGeneration++; + try { + await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: approval.operationId, approvalToken: approval.approvalToken + }, voteSitesSyncStatus); + } catch (error) { text(voteSitesSyncStatus, error.message); } +}); + +transportTestProxy.addEventListener('change', () => { + transportTestProxyId = transportTestProxy.value; + transportTestBackendId = ''; + renderTransportTest(); + text(transportTestStatus, 'Choose a backend, then run a live non-vote communication test.'); + updateConfigurationButtons(); +}); + +transportTestBackend.addEventListener('change', () => { + transportTestBackendId = transportTestBackend.value; + text(transportTestStatus, 'Ready to test the active VotingPlugin transport.'); + updateConfigurationButtons(); +}); + +runTransportTest.addEventListener('click', async () => { + const proxyId = transportTestProxyId; + const server = transportTestBackendId; + 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) { + text(transportTestStatus, 'The proxy or backend changed while testing. Run the test again.'); + return; + } + text(transportTestStatus, operationSummary(operation)); + } catch (error) { text(transportTestStatus, error.message); } +}); + +proxyMethodProxy.addEventListener('change', () => { + proxyMethodProxyId = proxyMethodProxy.value; + renderProxyMethod(); + const network = proxyMethodNetwork(); + text(proxyMethodStatus, network.unavailable.length > 0 + ? `Cannot switch yet. Enroll, update, and connect: ${network.unavailable.map(backend => backend.displayName).join(', ')}.` + : 'Choose a method to preflight every node before applying.'); + updateConfigurationButtons(); +}); + +proxyMethodButtons.forEach(button => button.addEventListener('click', async () => { + const method = button.dataset.proxyMethod; + const network = proxyMethodNetwork(); + if (!network.proxy || !network.topologyComplete || network.reported.length === 0 || network.unavailable.length > 0) return; + try { + const preview = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds: network.nodeIds, + configuration: {domain: 'quick-setup', preset: 'proxy-method', options: {method}} + }, proxyMethodStatus); + if (preview.state !== 'SUCCEEDED' || !preview.approvalToken) return; + if (proxyMethodProxyId !== network.proxy.nodeId) { + text(proxyMethodStatus, 'The selected proxy changed while preflighting. Choose the method again.'); + return; + } + if (!window.confirm(`Switch ${network.nodeIds.length} VotingPlugin nodes to ${method}? ` + + 'The proxy runtime will restart after Control records the result.')) return; + const applied = await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: preview.operationId, approvalToken: preview.approvalToken + }, proxyMethodStatus); + text(proxyMethodStatus, `${operationSummary(applied)}\nReconnect the proxy if needed, then run the communication test.`); + } catch (error) { text(proxyMethodStatus, error.message); } +})); + [configurationContent, quickName, quickService, quickUrl, quickDelay, quickRewardScope, quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 7c8ea762..cca2f4d9 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -63,9 +63,10 @@

Sign in to your network

-

+

+ -
-
NextProxy methodOne-click selection with required-setting checks
-
NextCommunication testNon-vote connectivity checks without rewards
-
NextSync VoteSitesCopy site definitions while preserving rewards
+
+
+
+

Network transport

+

Proxy method

+

Choose a method to preflight its required settings on the proxy and every reported backend, then approve the coordinated switch.

+
+ Waiting for a complete network +
+ +
+ + + + + +
+
Choose a proxy. All of its reported backends must be online and method-capable.
+
+
+
+
+

Live diagnostic

+

Test proxy communication

+

Send a correlated status request over the active VotingPlugin transport. No vote, totals, commands, or rewards are processed.

+
+ Waiting for a capable proxy +
+
+ + +
+
+ +
+
Choose a proxy and one of its configured backends.
+
+
+
+
+

Network action

+

Sync VoteSites

+

Copy site definitions from one backend. Target-only sites and every reward-named section are preserved.

+
+ Waiting for capable backends +
+
+ +
+ Target backends +
No sync-capable backends loaded.
+
+
+
+ + +
+
Choose a source and at least one target. Rewards are never copied or removed.
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 b3acce6c..b7dd0a44 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -65,15 +65,22 @@ class ControlHttpServerTest { assertTrue(web.body().contains("id=\"server-picker\"")); assertTrue(web.body().contains("Full YAML")); assertTrue(web.body().contains("Comment support unknown")); + assertTrue(web.body().contains("Sync VoteSites")); + assertTrue(web.body().contains("Rewards are never copied or removed")); assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); + assertTrue(script.body().contains("async function loadAllNodes()")); + assertTrue(script.body().contains("enrollmentIds.has(backend.backendId)")); assertTrue(script.body().contains("nextPage.addEventListener")); assertTrue(script.body().contains("result.configuration?.content != null")); assertTrue(script.body().contains("authenticationGeneration")); assertTrue(script.body().contains("if (loginInFlight) return")); assertTrue(script.body().contains("backendItemsTruncated")); + assertTrue(script.body().contains("topologyComplete: !backendTopologyTruncated")); + assertTrue(script.body().contains("option.value = backend.backendId")); + assertTrue(script.body().contains("return allNodeItems.filter(node => isProxy(node)")); assertTrue(web.body().contains("Easy vote reward")); assertTrue(web.body().contains("First-run setup")); assertTrue(web.body().contains("Node enrollment")); @@ -103,6 +110,17 @@ class ControlHttpServerTest { assertTrue(script.body().contains("No connected proxy reports this backend ID")); assertTrue(script.body().contains("'config.files.v1': 'Full configuration'")); assertTrue(script.body().contains("'config.file-comments.v1': 'Comments preserved'")); + assertTrue(script.body().contains("'config.vote-sites-sync.v1': 'VoteSites sync'")); + assertTrue(script.body().contains("'config.transport-test.v1': 'Communication test'")); + assertTrue(script.body().contains("'config.proxy-method.v1': 'Proxy method'")); + assertTrue(script.body().contains("preset: 'sync-vote-sites'")); + assertTrue(script.body().contains("return allNodeItems.filter(node => isBackend(node)")); + assertTrue(script.body().contains("A sync target became unavailable")); + assertTrue(script.body().contains("sourceContent: source")); + assertTrue(script.body().contains("preset: 'communication-test'")); + assertTrue(script.body().contains("runTransportTest.addEventListener")); + assertTrue(script.body().contains("preset: 'proxy-method'")); + assertTrue(script.body().contains("proxyMethodButtons.forEach")); assertTrue(script.body().contains("handleEditorKeydown")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); From 0a72641bb88c1a1ab3bd8b86f5b2a454d0f39b1f Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:16:12 -0600 Subject: [PATCH 11/26] Restore stack boundary after workspace fixes --- src/main/resources/web/app.js | 353 +----------------- src/main/resources/web/index.html | 72 +--- .../control/http/ControlHttpServerTest.java | 16 - 3 files changed, 12 insertions(+), 429 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index fd213df0..94da5bbd 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -77,21 +77,6 @@ const quickPartyOnline = document.querySelector('#quick-party-online'); const previewQuickSetup = document.querySelector('#preview-quick-setup'); const applyQuickSetup = document.querySelector('#apply-quick-setup'); const quickOperationStatus = document.querySelector('#quick-operation-status'); -const voteSitesSource = document.querySelector('#vote-sites-source'); -const voteSitesTargets = document.querySelector('#vote-sites-targets'); -const voteSitesSyncCapability = document.querySelector('#vote-sites-sync-capability'); -const previewVoteSitesSync = document.querySelector('#preview-vote-sites-sync'); -const applyVoteSitesSync = document.querySelector('#apply-vote-sites-sync'); -const voteSitesSyncStatus = document.querySelector('#vote-sites-sync-status'); -const transportTestProxy = document.querySelector('#transport-test-proxy'); -const transportTestBackend = document.querySelector('#transport-test-backend'); -const transportTestCapability = document.querySelector('#transport-test-capability'); -const runTransportTest = document.querySelector('#run-transport-test'); -const transportTestStatus = document.querySelector('#transport-test-status'); -const proxyMethodProxy = document.querySelector('#proxy-method-proxy'); -const proxyMethodCapability = document.querySelector('#proxy-method-capability'); -const proxyMethodButtons = [...document.querySelectorAll('[data-proxy-method]')]; -const proxyMethodStatus = document.querySelector('#proxy-method-status'); const enrollmentCard = document.querySelector('#enrollment-card'); const enrollmentForm = document.querySelector('#enrollment-form'); const enrollmentSubmit = enrollmentForm.querySelector('button[type="submit"]'); @@ -113,14 +98,6 @@ let enrollmentIds = new Set(); let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; -let approvedVoteSitesPreview = null; -let voteSitesSourceId = ''; -let voteSitesTargetIds = new Set(); -let voteSitesTargetsInitialized = false; -let transportTestProxyId = ''; -let transportTestBackendId = ''; -let proxyMethodProxyId = ''; -let backendTopologyTruncated = false; let nodeCapabilities = new Map(); let nodePlugins = new Map(); let inputGeneration = 0; @@ -178,14 +155,7 @@ function applyAuthenticatedSession(body) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; - approvedVoteSitesPreview = null; selectedNodes.clear(); - voteSitesSourceId = ''; - voteSitesTargetIds.clear(); - voteSitesTargetsInitialized = false; - transportTestProxyId = ''; - transportTestBackendId = ''; - proxyMethodProxyId = ''; configurationContent.value = ''; inputGeneration++; logout.hidden = false; @@ -223,9 +193,6 @@ function friendlyCapability(capability) { return ({ 'config.files.v1': 'Full configuration', 'config.file-comments.v1': 'Comments preserved', - 'config.vote-sites-sync.v1': 'VoteSites sync', - 'config.transport-test.v1': 'Communication test', - 'config.proxy-method.v1': 'Proxy method', 'config.quick-setup.v1': 'Quick Setup', 'config.proxy-routing.v1': 'Proxy routing' })[capability]; @@ -466,163 +433,6 @@ function renderMetrics() { text(metricIssues, allNodeItems.filter(node => !node.online).length + unenrolledBackends); } -function syncSourceCandidates() { - return allNodeItems.filter(node => isBackend(node) && node.online && - node.acceptedCapabilities.includes('config.files.v1') && - node.acceptedCapabilities.includes('config.file-comments.v1')); -} - -function syncTargetCandidates() { - return allNodeItems.filter(node => isBackend(node) && node.online && - node.acceptedCapabilities.includes('config.vote-sites-sync.v1')); -} - -function selectedVoteSitesTargets() { - const capable = new Set(syncTargetCandidates().map(node => node.nodeId)); - return [...voteSitesTargetIds].filter(nodeId => capable.has(nodeId) && nodeId !== voteSitesSourceId); -} - -function renderVoteSitesSync() { - const sources = syncSourceCandidates(); - const targetsAvailable = syncTargetCandidates(); - if (!sources.some(node => node.nodeId === voteSitesSourceId)) { - voteSitesSourceId = sources.find(node => node.nodeId === selectedServerId)?.nodeId || sources[0]?.nodeId || ''; - } - const targetIds = new Set(targetsAvailable.map(node => node.nodeId)); - const retainedTargets = new Set([...voteSitesTargetIds].filter(nodeId => - targetIds.has(nodeId) && nodeId !== voteSitesSourceId)); - if (retainedTargets.size !== voteSitesTargetIds.size) { - approvedVoteSitesPreview = null; - inputGeneration++; - text(voteSitesSyncStatus, 'A sync target became unavailable. Preview again before syncing.'); - } - voteSitesTargetIds = retainedTargets; - if (!voteSitesTargetsInitialized && sources.length > 0) { - voteSitesTargetIds = new Set(targetsAvailable.map(node => node.nodeId) - .filter(nodeId => nodeId !== voteSitesSourceId)); - voteSitesTargetsInitialized = true; - } - - const placeholder = text(document.createElement('option'), sources.length ? 'Choose a source' : 'No readable backends'); - placeholder.value = ''; - voteSitesSource.replaceChildren(placeholder, ...sources.map(node => { - const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); - option.value = node.nodeId; - return option; - })); - voteSitesSource.value = voteSitesSourceId; - - voteSitesTargets.replaceChildren(); - const choices = targetsAvailable.filter(node => node.nodeId !== voteSitesSourceId); - voteSitesTargets.classList.toggle('empty', choices.length === 0); - if (choices.length === 0) { - text(voteSitesTargets, targetsAvailable.length > 0 - ? 'Choose a different source or enroll another backend.' : 'No sync-capable target backends are connected.'); - } else { - choices.forEach(node => { - const label = document.createElement('label'); - label.className = 'target-option'; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.checked = voteSitesTargetIds.has(node.nodeId); - checkbox.addEventListener('change', () => { - if (checkbox.checked) voteSitesTargetIds.add(node.nodeId); else voteSitesTargetIds.delete(node.nodeId); - approvedVoteSitesPreview = null; - inputGeneration++; - text(voteSitesSyncStatus, 'Targets changed. Read the source and preview again before syncing.'); - updateConfigurationButtons(); - }); - label.append(checkbox, document.createTextNode(`${node.displayName} · ${node.nodeId}`)); - voteSitesTargets.append(label); - }); - } - const readyCount = targetsAvailable.length; - text(voteSitesSyncCapability, readyCount > 0 - ? `${readyCount} sync-capable ${readyCount === 1 ? 'backend' : 'backends'}` : 'Waiting for capable backends'); - voteSitesSyncCapability.className = `pill ${readyCount > 0 ? 'online' : 'neutral'}`; -} - -function transportTestProxies() { - return allNodeItems.filter(node => isProxy(node) && node.online && - node.acceptedCapabilities.includes('config.transport-test.v1')); -} - -function renderTransportTest() { - const proxies = transportTestProxies(); - if (!proxies.some(node => node.nodeId === transportTestProxyId)) { - transportTestProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; - transportTestBackendId = ''; - } - const proxy = proxies.find(node => node.nodeId === transportTestProxyId); - const backends = Array.isArray(proxy?.backends) ? proxy.backends : []; - if (!backends.some(backend => backend.backendId === transportTestBackendId)) { - transportTestBackendId = backends[0]?.backendId || ''; - } - - const proxyPlaceholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); - proxyPlaceholder.value = ''; - transportTestProxy.replaceChildren(proxyPlaceholder, ...proxies.map(node => { - const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); - option.value = node.nodeId; - return option; - })); - transportTestProxy.value = transportTestProxyId; - - const backendPlaceholder = text(document.createElement('option'), backends.length ? 'Choose a backend' : 'No backends reported'); - backendPlaceholder.value = ''; - transportTestBackend.replaceChildren(backendPlaceholder, ...backends.map(backend => { - const option = text(document.createElement('option'), `${backend.displayName} · ${backend.backendId}`); - option.value = backend.backendId; - return option; - })); - transportTestBackend.value = transportTestBackendId; - - text(transportTestCapability, proxies.length > 0 - ? `${proxies.length} test-capable ${proxies.length === 1 ? 'proxy' : 'proxies'}` : 'Waiting for a capable proxy'); - transportTestCapability.className = `pill ${proxies.length > 0 ? 'online' : 'neutral'}`; -} - -function proxyMethodCandidates() { - return allNodeItems.filter(node => isProxy(node) && node.online && - node.acceptedCapabilities.includes('config.proxy-method.v1')); -} - -function proxyMethodNetwork() { - const proxy = nodeIndex.get(proxyMethodProxyId); - const reported = Array.isArray(proxy?.backends) ? proxy.backends : []; - const backends = reported.map(backend => nodeIndex.get(backend.backendId)).filter(Boolean); - const unavailable = reported.filter(backend => { - const node = nodeIndex.get(backend.backendId); - return !node || !node.online || !node.acceptedCapabilities.includes('config.proxy-method.v1'); - }); - return {proxy, reported, backends, unavailable, topologyComplete: !backendTopologyTruncated, - nodeIds: proxy ? [proxy.nodeId, ...backends.map(node => node.nodeId)] : []}; -} - -function renderProxyMethod() { - const proxies = proxyMethodCandidates(); - if (!proxies.some(node => node.nodeId === proxyMethodProxyId)) { - proxyMethodProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; - } - const placeholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); - placeholder.value = ''; - proxyMethodProxy.replaceChildren(placeholder, ...proxies.map(node => { - const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); - option.value = node.nodeId; - return option; - })); - proxyMethodProxy.value = proxyMethodProxyId; - const network = proxyMethodNetwork(); - const ready = Boolean(network.proxy) && network.topologyComplete && network.reported.length > 0 && network.unavailable.length === 0; - const description = !network.proxy ? 'Waiting for a capable proxy' - : !network.topologyComplete ? 'Backend topology is truncated; switching disabled' - : network.reported.length === 0 ? 'No backends reported' - : network.unavailable.length > 0 ? `${network.unavailable.length} backends unavailable` - : `${network.nodeIds.length} nodes ready`; - text(proxyMethodCapability, description); - proxyMethodCapability.className = `pill ${ready ? 'online' : 'neutral'}`; -} - function renderNodeViews() { nodes.replaceChildren(); nodes.classList.toggle('empty', visibleNodeItems.length === 0); @@ -634,9 +444,6 @@ function renderNodeViews() { renderMetrics(); renderTopology(); renderSelectedServer(); - renderVoteSitesSync(); - renderTransportTest(); - renderProxyMethod(); } function selectPrimaryServer(nodeId) { @@ -654,7 +461,6 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) const routingReady = authenticated && targets('config.proxy-routing.v1').length > 0 && !busy; const fileReady = authenticated && targets('config.files.v1').length > 0 && !busy; const quickReady = authenticated && targets('config.quick-setup.v1').length > 0 && !busy; - const voteSitesReady = authenticated && voteSitesSourceId && selectedVoteSitesTargets().length > 0 && !busy; readConfiguration.disabled = !routingReady; previewConfiguration.disabled = !routingReady; applyConfiguration.disabled = !routingReady || !approvedPreview; @@ -663,13 +469,6 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) applyFileConfiguration.disabled = !fileReady || !approvedFilePreview; previewQuickSetup.disabled = !quickReady; applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; - previewVoteSitesSync.disabled = !voteSitesReady; - applyVoteSitesSync.disabled = !voteSitesReady || !approvedVoteSitesPreview; - runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy; - const methodNetwork = proxyMethodNetwork(); - const methodReady = authenticated && Boolean(methodNetwork.proxy) && methodNetwork.topologyComplete && methodNetwork.reported.length > 0 && - methodNetwork.unavailable.length === 0 && !busy; - proxyMethodButtons.forEach(button => { button.disabled = !methodReady; }); } function targets(capability) { @@ -680,8 +479,6 @@ function clearApprovals() { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; - approvedVoteSitesPreview = null; - approvedVoteSitesPreview = null; inputGeneration++; updateConfigurationButtons(); } @@ -775,7 +572,6 @@ function discardAuthenticationState(reason) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; - approvedVoteSitesPreview = null; inputGeneration++; logout.hidden = true; appShell.hidden = true; @@ -787,13 +583,6 @@ function discardAuthenticationState(reason) { enrollmentList.replaceChildren(text(document.createElement('li'), 'Authenticate to manage enrollments.')); text(enrollmentMessage, ''); selectedNodes.clear(); - voteSitesSourceId = ''; - voteSitesTargetIds.clear(); - voteSitesTargetsInitialized = false; - transportTestProxyId = ''; - transportTestBackendId = ''; - backendTopologyTruncated = false; - proxyMethodProxyId = ''; selectedServerId = ''; visibleNodeItems = []; allNodeItems = []; @@ -810,9 +599,6 @@ function discardAuthenticationState(reason) { text(operationStatus, ''); text(fileOperationStatus, ''); text(quickOperationStatus, ''); - text(voteSitesSyncStatus, ''); - text(transportTestStatus, ''); - text(proxyMethodStatus, ''); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -922,12 +708,10 @@ async function loadEnrollments() { async function loadAllNodes() { const items = []; - let truncated = false; for (let offset = 0; ; offset += PAGE_SIZE) { const page = await authorized(`/api/v1/nodes?offset=${offset}&limit=${PAGE_SIZE}`); items.push(...page.items); - truncated ||= Boolean(page.backendItemsTruncated); - if (page.items.length < PAGE_SIZE) return {items, truncated}; + if (page.items.length < PAGE_SIZE) return items; } } @@ -943,13 +727,12 @@ async function loadNodes() { loadAllNodes() ]); visibleNodeItems = body.items; - allNodeItems = registry.items; - backendTopologyTruncated = registry.truncated; - nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); + allNodeItems = registry; + nodeIndex = new Map(registry.map(node => [node.nodeId, node])); const needsDefaultTargets = !selectedServerId || !nodeIndex.has(selectedServerId); const previousCapabilities = nodeCapabilities; - nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); - nodePlugins = new Map(registry.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) + nodeCapabilities = new Map(registry.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); + nodePlugins = new Map(registry.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'].some(capability => @@ -968,19 +751,14 @@ async function loadNodes() { nodeCapabilities.get(node)?.includes('config.files.v1')); const invalidQuickApproval = approvedQuickPreview && !approvedQuickPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.quick-setup.v1')); - const invalidVoteSitesApproval = approvedVoteSitesPreview && - (!approvedVoteSitesPreview.nodeIds.every(node => - nodeCapabilities.get(node)?.includes('config.vote-sites-sync.v1')) || - !nodeCapabilities.get(approvedVoteSitesPreview.sourceId)?.includes('config.file-comments.v1')); - if (invalidRoutingApproval || invalidFileApproval || invalidQuickApproval || invalidVoteSitesApproval) { + if (invalidRoutingApproval || invalidFileApproval || invalidQuickApproval) { if (invalidRoutingApproval) approvedPreview = null; if (invalidFileApproval) approvedFilePreview = null; if (invalidQuickApproval) approvedQuickPreview = null; - if (invalidVoteSitesApproval) approvedVoteSitesPreview = null; 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.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); if (filteredSelection.size !== selectedNodes.size) { @@ -1297,123 +1075,6 @@ applyQuickSetup.addEventListener('click', async () => { } catch (error) { text(quickOperationStatus, error.message); } }); -voteSitesSource.addEventListener('change', () => { - voteSitesSourceId = voteSitesSource.value; - voteSitesTargetIds.delete(voteSitesSourceId); - voteSitesTargetsInitialized = true; - approvedVoteSitesPreview = null; - inputGeneration++; - renderVoteSitesSync(); - text(voteSitesSyncStatus, 'Source changed. Read it and preview every target before syncing.'); - updateConfigurationButtons(); -}); - -previewVoteSitesSync.addEventListener('click', async () => { - approvedVoteSitesPreview = null; - const previewGeneration = inputGeneration; - const sourceId = voteSitesSourceId; - const nodeIds = selectedVoteSitesTargets(); - try { - const read = await startConfigurationOperation('/api/v1/configuration/read', { - nodeIds: [sourceId], configuration: {domain: 'file', fileName: 'VoteSites.yml'} - }, voteSitesSyncStatus); - const source = Object.values(read.results).find(result => - result.success && result.configuration?.content != null)?.configuration?.content; - if (source == null) throw new Error('The source backend did not return VoteSites.yml.'); - if (previewGeneration !== inputGeneration || sourceId !== voteSitesSourceId) { - text(voteSitesSyncStatus, 'The source or targets changed while reading. Preview again.'); - return; - } - const preview = await startConfigurationOperation('/api/v1/configuration/preview', { - nodeIds, - configuration: {domain: 'quick-setup', preset: 'sync-vote-sites', options: {sourceContent: source}} - }, voteSitesSyncStatus); - if (preview.state === 'SUCCEEDED' && preview.approvalToken && previewGeneration === inputGeneration) { - approvedVoteSitesPreview = {operationId: preview.operationId, approvalToken: preview.approvalToken, - nodeIds, sourceId}; - updateConfigurationButtons(); - } else if (previewGeneration !== inputGeneration) { - text(voteSitesSyncStatus, 'The source or targets changed while previewing. Preview again before syncing.'); - } - } catch (error) { text(voteSitesSyncStatus, error.message); } -}); - -applyVoteSitesSync.addEventListener('click', async () => { - if (!approvedVoteSitesPreview || !window.confirm( - 'Sync the previewed VoteSites definitions to every selected backend? Target rewards and target-only sites are preserved.')) return; - const approval = approvedVoteSitesPreview; - approvedVoteSitesPreview = null; - inputGeneration++; - try { - await startConfigurationOperation('/api/v1/configuration/apply', { - previewOperationId: approval.operationId, approvalToken: approval.approvalToken - }, voteSitesSyncStatus); - } catch (error) { text(voteSitesSyncStatus, error.message); } -}); - -transportTestProxy.addEventListener('change', () => { - transportTestProxyId = transportTestProxy.value; - transportTestBackendId = ''; - renderTransportTest(); - text(transportTestStatus, 'Choose a backend, then run a live non-vote communication test.'); - updateConfigurationButtons(); -}); - -transportTestBackend.addEventListener('change', () => { - transportTestBackendId = transportTestBackend.value; - text(transportTestStatus, 'Ready to test the active VotingPlugin transport.'); - updateConfigurationButtons(); -}); - -runTransportTest.addEventListener('click', async () => { - const proxyId = transportTestProxyId; - const server = transportTestBackendId; - 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) { - text(transportTestStatus, 'The proxy or backend changed while testing. Run the test again.'); - return; - } - text(transportTestStatus, operationSummary(operation)); - } catch (error) { text(transportTestStatus, error.message); } -}); - -proxyMethodProxy.addEventListener('change', () => { - proxyMethodProxyId = proxyMethodProxy.value; - renderProxyMethod(); - const network = proxyMethodNetwork(); - text(proxyMethodStatus, network.unavailable.length > 0 - ? `Cannot switch yet. Enroll, update, and connect: ${network.unavailable.map(backend => backend.displayName).join(', ')}.` - : 'Choose a method to preflight every node before applying.'); - updateConfigurationButtons(); -}); - -proxyMethodButtons.forEach(button => button.addEventListener('click', async () => { - const method = button.dataset.proxyMethod; - const network = proxyMethodNetwork(); - if (!network.proxy || !network.topologyComplete || network.reported.length === 0 || network.unavailable.length > 0) return; - try { - const preview = await startConfigurationOperation('/api/v1/configuration/preview', { - nodeIds: network.nodeIds, - configuration: {domain: 'quick-setup', preset: 'proxy-method', options: {method}} - }, proxyMethodStatus); - if (preview.state !== 'SUCCEEDED' || !preview.approvalToken) return; - if (proxyMethodProxyId !== network.proxy.nodeId) { - text(proxyMethodStatus, 'The selected proxy changed while preflighting. Choose the method again.'); - return; - } - if (!window.confirm(`Switch ${network.nodeIds.length} VotingPlugin nodes to ${method}? ` + - 'The proxy runtime will restart after Control records the result.')) return; - const applied = await startConfigurationOperation('/api/v1/configuration/apply', { - previewOperationId: preview.operationId, approvalToken: preview.approvalToken - }, proxyMethodStatus); - text(proxyMethodStatus, `${operationSummary(applied)}\nReconnect the proxy if needed, then run the communication test.`); - } catch (error) { text(proxyMethodStatus, error.message); } -})); - [configurationContent, quickName, quickService, quickUrl, quickDelay, quickRewardScope, quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index cca2f4d9..c3f41422 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -136,75 +136,13 @@

Servers

Global VotingPlugin settings

Network

-

Manage network-wide VotingPlugin behavior and verify the live proxy-to-backend transport without sending a vote.

+

Routing currently uses the existing proxy capability. Method checks, transport tests, and VoteSites sync will appear here as nodes advertise those capabilities.

-
-
-
-

Network transport

-

Proxy method

-

Choose a method to preflight its required settings on the proxy and every reported backend, then approve the coordinated switch.

-
- Waiting for a complete network -
- -
- - - - - -
-
Choose a proxy. All of its reported backends must be online and method-capable.
-
-
-
-
-

Live diagnostic

-

Test proxy communication

-

Send a correlated status request over the active VotingPlugin transport. No vote, totals, commands, or rewards are processed.

-
- Waiting for a capable proxy -
-
- - -
-
- -
-
Choose a proxy and one of its configured backends.
-
-
-
-
-

Network action

-

Sync VoteSites

-

Copy site definitions from one backend. Target-only sites and every reward-named section are preserved.

-
- Waiting for capable backends -
-
- -
- Target backends -
No sync-capable backends loaded.
-
-
-
- - -
-
Choose a source and at least one target. Rewards are never copied or removed.
+
+
NextProxy methodOne-click selection with required-setting checks
+
NextCommunication testNon-vote connectivity checks without rewards
+
NextSync VoteSitesCopy site definitions while preserving rewards
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 b7dd0a44..cc40c882 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -65,8 +65,6 @@ class ControlHttpServerTest { assertTrue(web.body().contains("id=\"server-picker\"")); assertTrue(web.body().contains("Full YAML")); assertTrue(web.body().contains("Comment support unknown")); - assertTrue(web.body().contains("Sync VoteSites")); - assertTrue(web.body().contains("Rewards are never copied or removed")); assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); @@ -78,9 +76,6 @@ class ControlHttpServerTest { assertTrue(script.body().contains("authenticationGeneration")); assertTrue(script.body().contains("if (loginInFlight) return")); assertTrue(script.body().contains("backendItemsTruncated")); - assertTrue(script.body().contains("topologyComplete: !backendTopologyTruncated")); - assertTrue(script.body().contains("option.value = backend.backendId")); - assertTrue(script.body().contains("return allNodeItems.filter(node => isProxy(node)")); assertTrue(web.body().contains("Easy vote reward")); assertTrue(web.body().contains("First-run setup")); assertTrue(web.body().contains("Node enrollment")); @@ -110,17 +105,6 @@ class ControlHttpServerTest { assertTrue(script.body().contains("No connected proxy reports this backend ID")); assertTrue(script.body().contains("'config.files.v1': 'Full configuration'")); assertTrue(script.body().contains("'config.file-comments.v1': 'Comments preserved'")); - assertTrue(script.body().contains("'config.vote-sites-sync.v1': 'VoteSites sync'")); - assertTrue(script.body().contains("'config.transport-test.v1': 'Communication test'")); - assertTrue(script.body().contains("'config.proxy-method.v1': 'Proxy method'")); - assertTrue(script.body().contains("preset: 'sync-vote-sites'")); - assertTrue(script.body().contains("return allNodeItems.filter(node => isBackend(node)")); - assertTrue(script.body().contains("A sync target became unavailable")); - assertTrue(script.body().contains("sourceContent: source")); - assertTrue(script.body().contains("preset: 'communication-test'")); - assertTrue(script.body().contains("runTransportTest.addEventListener")); - assertTrue(script.body().contains("preset: 'proxy-method'")); - assertTrue(script.body().contains("proxyMethodButtons.forEach")); assertTrue(script.body().contains("handleEditorKeydown")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); From 3a7a12a7e8c0c72213fba6bd3ca079b4d0112bfc Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:30:03 -0600 Subject: [PATCH 12/26] Address second Codex workspace review --- src/main/resources/web/app.js | 38 ++++++++++++++----- .../control/http/ControlHttpServerTest.java | 3 ++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 94da5bbd..c3db67f0 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -95,6 +95,7 @@ let visibleNodeItems = []; let allNodeItems = []; let nodeIndex = new Map(); let enrollmentIds = new Set(); +let backendTopologyTruncated = false; let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; @@ -364,8 +365,10 @@ 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 preservesComments = selected.acceptedCapabilities.includes('config.file-comments.v1'); - text(commentPreservationState, preservesComments ? 'Comments preserved' : 'Comments not guaranteed'); + const fileTargets = targets('config.files.v1'); + 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'); commentPreservationState.className = `pill ${preservesComments ? 'online' : 'warning'}`; const capabilities = managedCapabilities(selected); if (capabilities.length === 0) capabilities.push('Discovery only'); @@ -421,6 +424,11 @@ function renderTopology() { row.append(proxyIdentity, backendList); topology.append(row); }); + if (backendTopologyTruncated) { + const warning = text(document.createElement('p'), 'Backend topology is truncated; some proxy relationships are not shown.'); + warning.className = 'warning-text'; + topology.prepend(warning); + } } function renderMetrics() { @@ -429,8 +437,9 @@ function renderMetrics() { const unenrolledBackends = [...backendIds].filter(backendId => !enrollmentIds.has(backendId)).length; text(metricNodes, allNodeItems.length); text(metricOnline, allNodeItems.filter(node => node.online).length); - text(metricBackends, backendIds.size); - text(metricIssues, allNodeItems.filter(node => !node.online).length + unenrolledBackends); + text(metricBackends, backendTopologyTruncated ? `${backendIds.size}+` : backendIds.size); + const issueCount = allNodeItems.filter(node => !node.online).length + unenrolledBackends; + text(metricIssues, backendTopologyTruncated ? `${issueCount}+` : issueCount); } function renderNodeViews() { @@ -452,6 +461,11 @@ function selectPrimaryServer(nodeId) { serverPicker.value = nodeId; selectedNodes.clear(); if (nodeId) selectedNodes.add(nodeId); + configurationForm.reset(); + configurationContent.value = ''; + text(operationStatus, 'Server changed. Read this server before previewing changes.'); + text(fileOperationStatus, 'Server changed. Read this file before previewing changes.'); + updateEditorPosition(); clearApprovals(); updatePluginSuggestions(); renderNodeViews(); @@ -587,6 +601,7 @@ function discardAuthenticationState(reason) { visibleNodeItems = []; allNodeItems = []; enrollmentIds.clear(); + backendTopologyTruncated = false; nodeIndex.clear(); nodeCapabilities.clear(); nodePlugins.clear(); @@ -708,10 +723,12 @@ async function loadEnrollments() { async function loadAllNodes() { const items = []; + let truncated = false; for (let offset = 0; ; offset += PAGE_SIZE) { const page = await authorized(`/api/v1/nodes?offset=${offset}&limit=${PAGE_SIZE}`); items.push(...page.items); - if (page.items.length < PAGE_SIZE) return items; + truncated ||= Boolean(page.backendItemsTruncated); + if (page.items.length < PAGE_SIZE) return {items, truncated}; } } @@ -727,12 +744,13 @@ async function loadNodes() { loadAllNodes() ]); visibleNodeItems = body.items; - allNodeItems = registry; - nodeIndex = new Map(registry.map(node => [node.nodeId, node])); + allNodeItems = registry.items; + backendTopologyTruncated = registry.truncated; + nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); const needsDefaultTargets = !selectedServerId || !nodeIndex.has(selectedServerId); const previousCapabilities = nodeCapabilities; - nodeCapabilities = new Map(registry.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); - nodePlugins = new Map(registry.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) + nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); + 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'].some(capability => @@ -758,7 +776,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.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.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); if (filteredSelection.size !== selectedNodes.size) { 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 cc40c882..5aa4f31a 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -71,6 +71,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("async function loadAllNodes()")); assertTrue(script.body().contains("enrollmentIds.has(backend.backendId)")); + assertTrue(script.body().contains("Comments preserved for every target")); + assertTrue(script.body().contains("Backend topology is truncated")); + assertTrue(script.body().contains("Server changed. Read this file")); assertTrue(script.body().contains("nextPage.addEventListener")); assertTrue(script.body().contains("result.configuration?.content != null")); assertTrue(script.body().contains("authenticationGeneration")); From 26373bbfd4001086d5b56a4a4c94b5f6e1a87de9 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:37:36 -0600 Subject: [PATCH 13/26] Refresh comment guarantee for target changes --- src/main/resources/web/app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index c3db67f0..cbd3d161 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -278,6 +278,7 @@ function nodeCard(node) { approvedQuickPreview = null; inputGeneration++; updatePluginSuggestions(); + renderSelectedServer(); updateConfigurationButtons(); }); selector.append(checkbox, document.createTextNode('Include in configuration changes')); From 97873af410cf3d4516428a923ae763166bc5626a Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:47:49 -0600 Subject: [PATCH 14/26] Fix refreshed server editor state --- src/main/resources/web/app.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index cbd3d161..762faba2 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -86,6 +86,7 @@ const enrollmentList = document.querySelector('#enrollment-list'); const enrollmentMessage = document.querySelector('#enrollment-message'); const refreshEnrollments = document.querySelector('#refresh-enrollments'); const PAGE_SIZE = 100; +const MAX_CONFIGURATION_TARGETS = 100; let authenticated = false; let csrfToken = ''; let pageOffset = 0; @@ -272,6 +273,11 @@ function nodeCard(node) { checkbox.disabled = !node.online || !controllable; checkbox.checked = selectedNodes.has(node.nodeId) && !checkbox.disabled; checkbox.addEventListener('change', () => { + if (checkbox.checked && selectedNodes.size >= MAX_CONFIGURATION_TARGETS) { + checkbox.checked = false; + text(operationStatus, `At most ${MAX_CONFIGURATION_TARGETS} servers can be configured at once.`); + return; + } if (checkbox.checked) selectedNodes.add(node.nodeId); else selectedNodes.delete(node.nodeId); approvedPreview = null; approvedFilePreview = null; @@ -339,7 +345,10 @@ function renderServerPicker() { option.value = node.nodeId; return option; })); - if (!nodeIndex.has(previousValue)) selectedServerId = chooseDefaultServer(ordered)?.nodeId || ''; + if (!nodeIndex.has(previousValue)) { + selectedServerId = chooseDefaultServer(ordered)?.nodeId || ''; + if (previousValue) resetServerConfigurationForms('The selected server is no longer available. Read the replacement server before previewing changes.'); + } serverPicker.value = selectedServerId; } @@ -456,18 +465,22 @@ function renderNodeViews() { renderSelectedServer(); } +function resetServerConfigurationForms(status) { + configurationForm.reset(); + configurationContent.value = ''; + text(operationStatus, status); + text(fileOperationStatus, status); + updateEditorPosition(); + clearApprovals(); +} + function selectPrimaryServer(nodeId) { if (nodeId && !nodeIndex.has(nodeId)) return; selectedServerId = nodeId; serverPicker.value = nodeId; selectedNodes.clear(); if (nodeId) selectedNodes.add(nodeId); - configurationForm.reset(); - configurationContent.value = ''; - text(operationStatus, 'Server changed. Read this server before previewing changes.'); - text(fileOperationStatus, 'Server changed. Read this file before previewing changes.'); - updateEditorPosition(); - clearApprovals(); + resetServerConfigurationForms('Server changed. Read this server before previewing changes.'); updatePluginSuggestions(); renderNodeViews(); } From 0e240bec3e9166e592df9f2fda2860cb608e5fa6 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:52:44 -0600 Subject: [PATCH 15/26] Update editor reset regression assertion --- .../votingplugin/control/http/ControlHttpServerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5aa4f31a..81107c79 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -73,7 +73,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("enrollmentIds.has(backend.backendId)")); assertTrue(script.body().contains("Comments preserved for every target")); assertTrue(script.body().contains("Backend topology is truncated")); - assertTrue(script.body().contains("Server changed. Read this file")); + assertTrue(script.body().contains("function resetServerConfigurationForms(status)")); assertTrue(script.body().contains("nextPage.addEventListener")); assertTrue(script.body().contains("result.configuration?.content != null")); assertTrue(script.body().contains("authenticationGeneration")); From 7ec18a1b31c530dfd241473395ed201b3b2d57d8 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:56:28 -0600 Subject: [PATCH 16/26] Exclude offline proxy topology reports --- src/main/resources/web/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 762faba2..2956d4d5 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -205,7 +205,7 @@ function managedCapabilities(node) { } function proxyReportsFor(backendId) { - return allNodeItems.filter(isProxy).filter(proxy => + return allNodeItems.filter(node => isProxy(node) && node.online).filter(proxy => (Array.isArray(proxy.backends) ? proxy.backends : []).some(backend => backend.backendId === backendId)); } From 6ed82c31c6a0bf81a3ebf6489024b04ef6c606f7 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 16:02:24 -0600 Subject: [PATCH 17/26] Keep primary server in configuration targets --- src/main/resources/web/app.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 2956d4d5..6e7b894b 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -270,8 +270,9 @@ function nodeCard(node) { checkbox.type = 'checkbox'; const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1'] .some(capability => node.acceptedCapabilities.includes(capability)); - checkbox.disabled = !node.online || !controllable; - checkbox.checked = selectedNodes.has(node.nodeId) && !checkbox.disabled; + checkbox.disabled = !node.online || !controllable || node.nodeId === selectedServerId; + checkbox.checked = selectedNodes.has(node.nodeId); + if (node.nodeId === selectedServerId) selector.title = 'The primary server remains included in configuration changes.'; checkbox.addEventListener('change', () => { if (checkbox.checked && selectedNodes.size >= MAX_CONFIGURATION_TARGETS) { checkbox.checked = false; From 0d711c38f35e1d251ab8ff6fb607301747ab154b Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 16:19:18 -0600 Subject: [PATCH 18/26] Align primary workflow and issue metrics --- src/main/resources/web/app.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 6e7b894b..c93525e6 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -445,11 +445,16 @@ function renderTopology() { function renderMetrics() { const backendIds = new Set(allNodeItems.filter(isProxy).flatMap(node => (Array.isArray(node.backends) ? node.backends : []).map(backend => backend.backendId))); - const unenrolledBackends = [...backendIds].filter(backendId => !enrollmentIds.has(backendId)).length; + const issueIds = new Set(allNodeItems.filter(node => !node.online).map(node => node.nodeId)); + backendIds.forEach(backendId => { if (!enrollmentIds.has(backendId)) issueIds.add(backendId); }); + allNodeItems.filter(node => isProxy(node) && node.online).forEach(proxy => + (Array.isArray(proxy.backends) ? proxy.backends : []).forEach(backend => { + if (backend.presenceKnown && !backend.available) issueIds.add(backend.backendId); + })); text(metricNodes, allNodeItems.length); text(metricOnline, allNodeItems.filter(node => node.online).length); text(metricBackends, backendTopologyTruncated ? `${backendIds.size}+` : backendIds.size); - const issueCount = allNodeItems.filter(node => !node.online).length + unenrolledBackends; + const issueCount = issueIds.size; text(metricIssues, backendTopologyTruncated ? `${issueCount}+` : issueCount); } @@ -487,9 +492,13 @@ function selectPrimaryServer(nodeId) { } function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) { - const routingReady = authenticated && targets('config.proxy-routing.v1').length > 0 && !busy; - const fileReady = authenticated && targets('config.files.v1').length > 0 && !busy; - const quickReady = authenticated && targets('config.quick-setup.v1').length > 0 && !busy; + 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 quickReady = authenticated && primaryCapabilities.includes('config.quick-setup.v1') && + targets('config.quick-setup.v1').length > 0 && !busy; readConfiguration.disabled = !routingReady; previewConfiguration.disabled = !routingReady; applyConfiguration.disabled = !routingReady || !approvedPreview; From e93462df1c48d9d025d7479d65478a5e15b504a5 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 16:36:16 -0600 Subject: [PATCH 19/26] Preserve unknown enrollment state --- src/main/resources/web/app.js | 17 ++++++++++++++--- .../control/http/ControlHttpServerTest.java | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index c93525e6..9bb76217 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -96,6 +96,7 @@ let visibleNodeItems = []; let allNodeItems = []; let nodeIndex = new Map(); let enrollmentIds = new Set(); +let enrollmentsLoaded = false; let backendTopologyTruncated = false; let approvedPreview = null; let approvedFilePreview = null; @@ -215,7 +216,9 @@ function backendCard(backend) { const details = document.createElement('div'); details.className = 'backend-state'; const registered = nodeIndex.get(backend.backendId); - details.append(text(document.createElement('span'), enrollmentIds.has(backend.backendId) + details.append(text(document.createElement('span'), !enrollmentsLoaded + ? 'Control enrollment unavailable' + : enrollmentIds.has(backend.backendId) ? `Enrolled in Control · ${registered ? (registered.online ? 'Control connected' : 'Control disconnected') : 'not registered'}` : 'Not enrolled in Control')); if (backend.presenceKnown) { @@ -395,7 +398,9 @@ function topologyLink(backend) { const link = document.createElement('span'); link.className = `topology-link ${registered?.online ? 'online' : 'warning'}`; link.append(text(document.createElement('strong'), backend.displayName)); - link.append(text(document.createElement('small'), enrollmentIds.has(backend.backendId) + link.append(text(document.createElement('small'), !enrollmentsLoaded + ? 'Control enrollment unavailable' + : enrollmentIds.has(backend.backendId) ? `Enrolled · Control ${registered ? (registered.online ? 'connected' : 'disconnected') : 'not registered'}` : 'Not enrolled in Control')); if (backend.presenceKnown) { @@ -446,7 +451,9 @@ function renderMetrics() { const backendIds = new Set(allNodeItems.filter(isProxy).flatMap(node => (Array.isArray(node.backends) ? node.backends : []).map(backend => backend.backendId))); const issueIds = new Set(allNodeItems.filter(node => !node.online).map(node => node.nodeId)); - backendIds.forEach(backendId => { if (!enrollmentIds.has(backendId)) issueIds.add(backendId); }); + if (enrollmentsLoaded) { + backendIds.forEach(backendId => { if (!enrollmentIds.has(backendId)) issueIds.add(backendId); }); + } allNodeItems.filter(node => isProxy(node) && node.online).forEach(proxy => (Array.isArray(proxy.backends) ? proxy.backends : []).forEach(backend => { if (backend.presenceKnown && !backend.available) issueIds.add(backend.backendId); @@ -625,6 +632,7 @@ function discardAuthenticationState(reason) { visibleNodeItems = []; allNodeItems = []; enrollmentIds.clear(); + enrollmentsLoaded = false; backendTopologyTruncated = false; nodeIndex.clear(); nodeCapabilities.clear(); @@ -702,6 +710,7 @@ async function loadEnrollments() { try { const body = await authorized('/api/v1/enrollments'); enrollmentIds = new Set(Array.isArray(body.nodeIds) ? body.nodeIds : []); + enrollmentsLoaded = true; if (allNodeItems.length) renderNodeViews(); enrollmentList.replaceChildren(); if (!Array.isArray(body.nodeIds) || body.nodeIds.length === 0) { @@ -735,6 +744,8 @@ async function loadEnrollments() { enrollmentList.append(item); }); } catch (error) { + enrollmentsLoaded = false; + if (allNodeItems.length) renderNodeViews(); text(enrollmentMessage, error.message || 'Enrollments could not be loaded.'); } finally { enrollmentInFlight = false; 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 81107c79..8dee4085 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -71,6 +71,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("async function loadAllNodes()")); assertTrue(script.body().contains("enrollmentIds.has(backend.backendId)")); + 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)")); From 477f54f0784f81086b4d7fcfcce78af27e781e0d Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 17:26:44 -0600 Subject: [PATCH 20/26] Restore eligible primary targets after refresh --- src/main/resources/web/app.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 9bb76217..f50ee03b 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -782,7 +782,6 @@ async function loadNodes() { allNodeItems = registry.items; backendTopologyTruncated = registry.truncated; nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); - const needsDefaultTargets = !selectedServerId || !nodeIndex.has(selectedServerId); const previousCapabilities = nodeCapabilities; nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); nodePlugins = new Map(registry.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) @@ -814,7 +813,10 @@ async function loadNodes() { const visibleIds = new Set(registry.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); - if (filteredSelection.size !== selectedNodes.size) { + renderServerPicker(); + if (selectedServerId && visibleIds.has(selectedServerId)) filteredSelection.add(selectedServerId); + if (filteredSelection.size !== selectedNodes.size || + [...filteredSelection].some(node => !selectedNodes.has(node))) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; @@ -822,11 +824,6 @@ async function loadNodes() { text(operationStatus, 'The selected nodes changed during refresh. Preview again before apply.'); } selectedNodes = filteredSelection; - renderServerPicker(); - if (needsDefaultTargets && selectedServerId && - nodeCapabilities.get(selectedServerId)?.some(value => value.startsWith('config.'))) { - selectedNodes.add(selectedServerId); - } renderNodeViews(); updatePluginSuggestions(); updateConfigurationButtons(); From 73bdcbb441d77974da380b7b111dcb3a053769ff Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 17:31:25 -0600 Subject: [PATCH 21/26] Bound restored targets and mark stale presence --- src/main/resources/web/app.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index f50ee03b..f0f82d8f 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -393,7 +393,7 @@ function renderSelectedServer() { }); } -function topologyLink(backend) { +function topologyLink(backend, reporterOnline) { const registered = nodeIndex.get(backend.backendId); const link = document.createElement('span'); link.className = `topology-link ${registered?.online ? 'online' : 'warning'}`; @@ -403,7 +403,9 @@ function topologyLink(backend) { : enrollmentIds.has(backend.backendId) ? `Enrolled · Control ${registered ? (registered.online ? 'connected' : 'disconnected') : 'not registered'}` : 'Not enrolled in Control')); - if (backend.presenceKnown) { + if (!reporterOnline) { + link.append(text(document.createElement('small'), 'Presence stale · reporting proxy disconnected')); + } else if (backend.presenceKnown) { link.append(text(document.createElement('small'), backend.available ? `Minecraft reachable · ${backend.playerCount} ${backend.playerCount === 1 ? 'player' : 'players'}` : 'Minecraft unavailable')); @@ -435,7 +437,7 @@ function renderTopology() { if (backends.length === 0) { backendList.append(text(document.createElement('small'), 'No configured backends reported by this proxy.')); } else { - backends.forEach(backend => backendList.append(topologyLink(backend))); + backends.forEach(backend => backendList.append(topologyLink(backend, proxy.online))); } row.append(proxyIdentity, backendList); topology.append(row); @@ -814,7 +816,14 @@ async function loadNodes() { .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); renderServerPicker(); - if (selectedServerId && visibleIds.has(selectedServerId)) filteredSelection.add(selectedServerId); + if (selectedServerId && visibleIds.has(selectedServerId)) { + while (!filteredSelection.has(selectedServerId) && filteredSelection.size >= MAX_CONFIGURATION_TARGETS) { + const removable = [...filteredSelection].find(node => node !== selectedServerId); + if (!removable) break; + filteredSelection.delete(removable); + } + filteredSelection.add(selectedServerId); + } if (filteredSelection.size !== selectedNodes.size || [...filteredSelection].some(node => !selectedNodes.has(node))) { approvedPreview = null; From a5b3a63c988116609848dc108a0f17885d5f7c58 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 17:46:15 -0600 Subject: [PATCH 22/26] Mark proxy card presence stale when disconnected --- src/main/resources/web/app.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index f0f82d8f..5044a57f 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -210,7 +210,7 @@ function proxyReportsFor(backendId) { (Array.isArray(proxy.backends) ? proxy.backends : []).some(backend => backend.backendId === backendId)); } -function backendCard(backend) { +function backendCard(backend, reporterOnline) { const item = document.createElement('li'); const title = text(document.createElement('strong'), backend.displayName); const details = document.createElement('div'); @@ -221,7 +221,9 @@ function backendCard(backend) { : enrollmentIds.has(backend.backendId) ? `Enrolled in Control · ${registered ? (registered.online ? 'Control connected' : 'Control disconnected') : 'not registered'}` : 'Not enrolled in Control')); - if (backend.presenceKnown) { + if (!reporterOnline) { + details.append(text(document.createElement('span'), 'Presence stale · reporting proxy disconnected')); + } else if (backend.presenceKnown) { details.append(text(document.createElement('span'), backend.available ? `Minecraft reachable · ${backend.playerCount} ${backend.playerCount === 1 ? 'player' : 'players'}` : 'Minecraft unavailable')); @@ -300,7 +302,7 @@ function nodeCard(node) { if (backends.length === 0) { list.append(text(document.createElement('li'), 'No configured backends reported by this proxy.')); } else { - backends.forEach(backend => list.append(backendCard(backend))); + backends.forEach(backend => list.append(backendCard(backend, node.online))); } } else { const proxies = proxyReportsFor(node.nodeId); From dd6afd452077df6429669decad9f2be743a46e8f Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 17:54:23 -0600 Subject: [PATCH 23/26] Count unregistered reported backends as issues --- src/main/resources/web/app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 5044a57f..7215748c 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -455,6 +455,7 @@ function renderMetrics() { const backendIds = new Set(allNodeItems.filter(isProxy).flatMap(node => (Array.isArray(node.backends) ? node.backends : []).map(backend => backend.backendId))); const issueIds = new Set(allNodeItems.filter(node => !node.online).map(node => node.nodeId)); + backendIds.forEach(backendId => { if (!nodeIndex.has(backendId)) issueIds.add(backendId); }); if (enrollmentsLoaded) { backendIds.forEach(backendId => { if (!enrollmentIds.has(backendId)) issueIds.add(backendId); }); } From 33882b8a90f5ecb821849a7b176a06cc40a3d1b1 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 18:09:39 -0600 Subject: [PATCH 24/26] Keep WebUI registry scans consistent --- .../control/domain/InMemoryNodeRegistry.java | 23 ++++-- .../control/domain/NodeRegistry.java | 2 + .../control/http/ControlHttpServer.java | 20 +++++- src/main/resources/web/app.js | 70 ++++++++++++++----- .../domain/InMemoryNodeRegistryTest.java | 15 ++++ .../control/http/ControlHttpServerTest.java | 19 +++++ 6 files changed, 124 insertions(+), 25 deletions(-) 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 6537ae8e..7d090de0 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -43,6 +43,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { private final int maxTotalDetectedPlugins; private int retainedBackends; private int retainedDetectedPlugins; + private long membershipRevision; public InMemoryNodeRegistry(Clock clock, Duration offlineTimeout) { this(clock, offlineTimeout, MAX_TOTAL_BACKENDS, MAX_TOTAL_DETECTED_PLUGINS); @@ -69,7 +70,7 @@ public InMemoryNodeRegistry(Clock clock, Duration offlineTimeout) { public synchronized RegistrationResult register(NodeRegistration registration) { validate(registration); Instant now = clock.instant(); - reclaimOfflineTopology(now, registration.nodeId()); + if (reclaimOfflineTopology(now, registration.nodeId())) membershipRevision++; StoredNode current = nodes.get(registration.nodeId()); int replacementPluginTotal = retainedDetectedPlugins - (current == null ? 0 : current.detectedPlugins.size()) + registration.detectedPlugins().size(); @@ -94,6 +95,7 @@ public synchronized RegistrationResult register(NodeRegistration registration) { return replacement; }); retainedDetectedPlugins = replacementPluginTotal; + if (created.get()) membershipRevision++; return new RegistrationResult(view(result.get()), created.get()); } @@ -128,7 +130,7 @@ public synchronized SnapshotResult replacePresence(String nodeId, PresenceSnapsh validateId(nodeId, "nodeId"); validateSnapshot(snapshot); Instant now = clock.instant(); - reclaimOfflineTopology(now, nodeId); + if (reclaimOfflineTopology(now, nodeId)) membershipRevision++; AtomicBoolean applied = new AtomicBoolean(); AtomicReference result = new AtomicReference<>(); nodes.computeIfPresent(nodeId, (ignored, existing) -> { @@ -161,11 +163,21 @@ public synchronized SnapshotResult replacePresence(String nodeId, PresenceSnapsh @Override public List list(int offset, int limit) { + return page(offset, limit, null).items(); + } + + @Override + public synchronized RegistryPage page(int offset, int limit, Long expectedRevision) { if (offset < 0 || limit < 1 || limit > 100) { throw invalid("offset must be >= 0 and limit must be between 1 and 100"); } - return nodes.values().stream().sorted(Comparator.comparing(node -> node.nodeId)) + if (expectedRevision != null && expectedRevision.longValue() != membershipRevision) { + throw new ValidationException("REGISTRY_CHANGED", "Node registry changed during pagination", + List.of("expectedRevision=" + expectedRevision, "actualRevision=" + membershipRevision)); + } + List items = nodes.values().stream().sorted(Comparator.comparing(node -> node.nodeId)) .skip(offset).limit(limit).map(this::view).toList(); + return new RegistryPage(items, membershipRevision, nodes.size()); } @Override @@ -210,16 +222,19 @@ private NodeStatus view(StoredNode node) { node.backends, node.snapshotSequence, node.lastSeen, node.lastAuthenticatedUpdate, online); } - private void reclaimOfflineTopology(Instant now, String excludedNodeId) { + private boolean reclaimOfflineTopology(Instant now, String excludedNodeId) { + AtomicBoolean reclaimed = new AtomicBoolean(); for (String candidate : List.copyOf(nodes.keySet())) { if (candidate.equals(excludedNodeId)) continue; nodes.computeIfPresent(candidate, (ignored, existing) -> { if (now.isBefore(existing.lastSeen.plus(offlineTimeout))) return existing; retainedDetectedPlugins -= existing.detectedPlugins.size(); retainedBackends -= existing.backends.size(); + reclaimed.set(true); return null; }); } + return reclaimed.get(); } private static void validate(NodeRegistration registration) { diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java index 0c846e3e..11b3c172 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java @@ -11,6 +11,7 @@ public interface NodeRegistry { NodeStatus heartbeat(String nodeId, Heartbeat heartbeat); SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot); List list(int offset, int limit); + RegistryPage page(int offset, int limit, Long expectedRevision); NodeStatus find(String nodeId); void requireSession(String nodeId, java.util.UUID sessionId); T withSession(String nodeId, java.util.UUID sessionId, @@ -18,4 +19,5 @@ T withSession(String nodeId, java.util.UUID sessionId, record RegistrationResult(NodeStatus node, boolean created) { } record SnapshotResult(NodeStatus node, boolean applied) { } + record RegistryPage(List items, long revision, int total) { } } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 34994fc5..20087036 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -224,7 +224,8 @@ private void handle(HttpExchange exchange) throws IOException { case "NODE_NOT_FOUND", "OPERATION_NOT_FOUND" -> 404; case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH", "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT", - "REGISTRY_LIMIT", "TASK_NOT_CLAIMED", "TASK_LEASE_EXPIRED", "SETUP_COMPLETE" -> 409; + "REGISTRY_LIMIT", "REGISTRY_CHANGED", "TASK_NOT_CLAIMED", "TASK_LEASE_EXPIRED", + "SETUP_COMPLETE" -> 409; case "UNSUPPORTED_MEDIA_TYPE" -> 415; default -> 400; }; @@ -380,8 +381,12 @@ private void route(HttpExchange exchange) throws IOException { Map query = query(uri.getRawQuery()); int offset = integer(query.getOrDefault("offset", "0"), "offset"); int limit = integer(query.getOrDefault("limit", "50"), "limit"); - BackendPage page = boundedNodePage(registry.list(offset, limit)); + Long expectedRevision = query.containsKey("revision") + ? longInteger(query.get("revision"), "revision") : null; + NodeRegistry.RegistryPage registryPage = registry.page(offset, limit, expectedRevision); + BackendPage page = boundedNodePage(registryPage.items()); send(exchange, 200, Map.of("items", page.items(), "offset", offset, "limit", limit, + "registryRevision", registryPage.revision(), "total", registryPage.total(), "backendItemsReturned", page.backendItemsReturned(), "backendItemsTruncated", page.backendItemsTruncated())); return; @@ -788,6 +793,17 @@ private static int integer(String value, String name) { } } + private static long longInteger(String value, String name) { + try { + long parsed = Long.parseLong(value); + if (parsed < 0) throw new NumberFormatException(); + return parsed; + } catch (NumberFormatException e) { + throw new ValidationException("VALIDATION_ERROR", "Request validation failed", + List.of(name + " must be a non-negative integer")); + } + } + private static Map query(String raw) { Map result = new HashMap<>(); if (raw == null || raw.isBlank()) { diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 7215748c..427cbbf5 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -87,6 +87,7 @@ const enrollmentMessage = document.querySelector('#enrollment-message'); const refreshEnrollments = document.querySelector('#refresh-enrollments'); const PAGE_SIZE = 100; const MAX_CONFIGURATION_TARGETS = 100; +const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; let csrfToken = ''; let pageOffset = 0; @@ -611,7 +612,11 @@ async function authorized(path, options = {}) { if (requestGeneration !== authenticationGeneration) { throw new Error('Authentication changed while the request was running. Try again.'); } - if (!response.ok) throw new Error(body?.error?.message || `Control request failed (${response.status}).`); + if (!response.ok) { + const error = new Error(body?.error?.message || `Control request failed (${response.status}).`); + error.code = body?.error?.code || ''; + throw error; + } return body; } @@ -762,14 +767,43 @@ async function loadEnrollments() { } async function loadAllNodes() { - const items = []; - let truncated = false; - for (let offset = 0; ; offset += PAGE_SIZE) { - const page = await authorized(`/api/v1/nodes?offset=${offset}&limit=${PAGE_SIZE}`); - items.push(...page.items); - truncated ||= Boolean(page.backendItemsTruncated); - if (page.items.length < PAGE_SIZE) return {items, truncated}; + for (let attempt = 0; attempt < MAX_REGISTRY_SCAN_ATTEMPTS; attempt++) { + const items = []; + const pageMetadata = new Map(); + const ids = new Set(); + let revision = null; + let total = null; + let truncated = false; + try { + for (let offset = 0; ; offset += PAGE_SIZE) { + const expected = revision == null ? '' : `&revision=${revision}`; + const page = await authorized(`/api/v1/nodes?offset=${offset}&limit=${PAGE_SIZE}${expected}`); + if (revision == null) { + revision = page.registryRevision; + total = page.total; + } else if (page.registryRevision !== revision || page.total !== total) { + throw Object.assign(new Error('Node registry changed during pagination.'), {code: 'REGISTRY_CHANGED'}); + } + if (page.items.some(node => ids.has(node.nodeId))) { + throw Object.assign(new Error('Node registry changed during pagination.'), {code: 'REGISTRY_CHANGED'}); + } + page.items.forEach(node => ids.add(node.nodeId)); + items.push(...page.items); + truncated ||= Boolean(page.backendItemsTruncated); + pageMetadata.set(offset, { + backendItemsReturned: page.backendItemsReturned, + backendItemsTruncated: Boolean(page.backendItemsTruncated) + }); + if (items.length === total) return {items, truncated, pageMetadata}; + if (page.items.length === 0 || items.length > total) { + throw Object.assign(new Error('Node registry changed during pagination.'), {code: 'REGISTRY_CHANGED'}); + } + } + } catch (error) { + if (error.code !== 'REGISTRY_CHANGED' || attempt + 1 === MAX_REGISTRY_SCAN_ATTEMPTS) throw error; + } } + throw new Error('Node registry could not be loaded consistently. Try refreshing again.'); } async function loadNodes() { @@ -779,11 +813,8 @@ async function loadNodes() { nextPage.disabled = true; text(message, 'Loading…'); try { - const [body, registry] = await Promise.all([ - authorized(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`), - loadAllNodes() - ]); - visibleNodeItems = body.items; + const registry = await loadAllNodes(); + visibleNodeItems = registry.items.slice(pageOffset, pageOffset + PAGE_SIZE); allNodeItems = registry.items; backendTopologyTruncated = registry.truncated; nodeIndex = new Map(registry.items.map(node => [node.nodeId, node])); @@ -839,15 +870,16 @@ async function loadNodes() { renderNodeViews(); updatePluginSuggestions(); updateConfigurationButtons(); - const first = body.items.length === 0 ? 0 : pageOffset + 1; - const last = pageOffset + body.items.length; - const backendLimit = body.backendItemsTruncated - ? ` Backend summaries are limited to ${body.backendItemsReturned} entries on this page.` : ''; - text(message, body.items.length === 0 ? 'No nodes on this page.' + const first = visibleNodeItems.length === 0 ? 0 : pageOffset + 1; + const last = pageOffset + visibleNodeItems.length; + const pageMeta = registry.pageMetadata.get(pageOffset); + const backendLimit = pageMeta?.backendItemsTruncated + ? ` Backend summaries are limited to ${pageMeta.backendItemsReturned} entries on this page.` : ''; + text(message, visibleNodeItems.length === 0 ? 'No nodes on this page.' : `Showing nodes ${first}–${last}.${backendLimit}`); text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`); previousPage.disabled = pageOffset === 0; - nextPage.disabled = body.items.length < PAGE_SIZE; + nextPage.disabled = pageOffset + visibleNodeItems.length >= registry.items.length; } catch (error) { nodes.replaceChildren(); nodes.classList.add('empty'); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 8ce5f94c..7148be01 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -179,6 +179,21 @@ class InMemoryNodeRegistryTest { assertFalse(registry.list(0, 1).get(0).online()); } + @Test void paginatedRegistryRevisionRejectsMembershipChanges() { + registry.register(registration("proxy-a", session, Set.of(), Set.of())); + NodeRegistry.RegistryPage first = registry.page(0, 1, null); + assertEquals(1, first.total()); + + registry.register(registration("proxy-b", UUID.randomUUID(), Set.of(), Set.of())); + + ValidationException changed = assertThrows(ValidationException.class, + () -> registry.page(1, 1, first.revision())); + assertEquals("REGISTRY_CHANGED", changed.code()); + NodeRegistry.RegistryPage current = registry.page(1, 1, null); + assertEquals(2, current.total()); + assertEquals("proxy-b", current.items().get(0).nodeId()); + } + @Test void validatesNodeBackendProtocolCollectionAndStringBounds() { assertThrows(ValidationException.class, () -> registry.register(registration("bad/id", session, Set.of(), Set.of()))); 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 8dee4085..8d2f5066 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -70,6 +70,8 @@ class ControlHttpServerTest { assertEquals(200, script.statusCode()); assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("async function loadAllNodes()")); + assertTrue(script.body().contains("MAX_REGISTRY_SCAN_ATTEMPTS")); + assertTrue(script.body().contains("&revision=${revision}")); assertTrue(script.body().contains("enrollmentIds.has(backend.backendId)")); assertTrue(script.body().contains("Control enrollment unavailable")); assertTrue(script.body().contains("Comments preserved for every target")); @@ -164,6 +166,23 @@ class ControlHttpServerTest { assertEquals("lobby", listed.at("/items/0/backends/0/backendId").asText()); assertTrue(listed.at("/items/0/detectedPlugins").toString().contains("LuckPerms")); assertTrue(listed.at("/items/0/online").asBoolean()); + assertEquals(1, listed.get("total").asInt()); + assertTrue(listed.has("registryRevision")); + } + + @Test void nodePaginationRejectsAStaleRegistryRevision() throws Exception { + assertEquals(201, send("POST", "/api/v1/nodes/register", registration(), nodeToken).statusCode()); + JsonNode first = json.readTree(get("/api/v1/nodes?offset=0&limit=1", adminToken).body()); + long revision = first.get("registryRevision").asLong(); + + String secondToken = credentials.rotateNode("proxy-b"); + String secondRegistration = registration() + .replace("proxy-a", "proxy-b") + .replace(SESSION, "00000000-0000-0000-0000-000000000002"); + assertEquals(201, send("POST", "/api/v1/nodes/register", secondRegistration, secondToken).statusCode()); + + assertError(get("/api/v1/nodes?offset=1&limit=1&revision=" + revision, adminToken), + 409, "REGISTRY_CHANGED"); } @Test void configurationPreviewApprovalAndRevisionCheckedApplyAreEndToEnd() throws Exception { From 30dd0163e0047fae4f59234ae8357cf82075c1d6 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 18:24:55 -0600 Subject: [PATCH 25/26] Update registry scan WebUI assertion --- .../votingplugin/control/http/ControlHttpServerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8d2f5066..aea22ab6 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -68,7 +68,7 @@ class ControlHttpServerTest { assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); - assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); + assertTrue(script.body().contains("offset=${offset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("async function loadAllNodes()")); assertTrue(script.body().contains("MAX_REGISTRY_SCAN_ATTEMPTS")); assertTrue(script.body().contains("&revision=${revision}")); From cd3e981ff50c936e406ebc9180c77f51bf59ae1a Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 18:32:07 -0600 Subject: [PATCH 26/26] Clear stale WebUI state after registry load failures --- src/main/resources/web/app.js | 16 ++++++++++++++-- .../control/http/ControlHttpServerTest.java | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 427cbbf5..d132bcdf 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -881,8 +881,20 @@ async function loadNodes() { previousPage.disabled = pageOffset === 0; nextPage.disabled = pageOffset + visibleNodeItems.length >= registry.items.length; } catch (error) { - nodes.replaceChildren(); - nodes.classList.add('empty'); + visibleNodeItems = []; + allNodeItems = []; + backendTopologyTruncated = false; + nodeIndex.clear(); + nodeCapabilities.clear(); + nodePlugins.clear(); + selectedNodes.clear(); + selectedServerId = ''; + resetServerConfigurationForms('Network data is unavailable. Refresh before editing.'); + text(quickOperationStatus, 'Network data is unavailable. Refresh before editing.'); + renderServerPicker(); + renderNodeViews(); + updatePluginSuggestions(); + updateConfigurationButtons(); text(nodes, 'Network data is unavailable.'); text(message, error.message || 'Control request failed.'); } finally { 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 aea22ab6..848415e1 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -77,6 +77,8 @@ class ControlHttpServerTest { 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("Network data is unavailable. Refresh before editing.")); + assertTrue(script.body().contains("backendTopologyTruncated = false;")); assertTrue(script.body().contains("nextPage.addEventListener")); assertTrue(script.body().contains("result.configuration?.content != null")); assertTrue(script.body().contains("authenticationGeneration"));