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..7d090de0 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"); @@ -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.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%; } +} diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index e76723a0..d132bcdf 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -13,6 +13,25 @@ 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 commentPreservationState = document.querySelector('#comment-preservation-state'); +const topology = document.querySelector('#topology'); const nodes = document.querySelector('#nodes'); const refresh = document.querySelector('#refresh'); const previousPage = document.querySelector('#previous-page'); @@ -27,6 +46,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'); @@ -66,10 +86,19 @@ 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; +const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; let csrfToken = ''; let pageOffset = 0; let selectedNodes = new Set(); +let selectedServerId = ''; +let visibleNodeItems = []; +let allNodeItems = []; +let nodeIndex = new Map(); +let enrollmentIds = new Set(); +let enrollmentsLoaded = false; +let backendTopologyTruncated = false; let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; @@ -134,62 +163,355 @@ 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 backendCard(backend) { +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.file-comments.v1': 'Comments preserved', + '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 allNodeItems.filter(node => isProxy(node) && node.online).filter(proxy => + (Array.isArray(proxy.backends) ? proxy.backends : []).some(backend => backend.backendId === backendId)); +} + +function backendCard(backend, reporterOnline) { 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'), !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 (!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')); + } 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'); 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; + 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; approvedQuickPreview = null; inputGeneration++; updatePluginSuggestions(); + renderSelectedServer(); 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, node.online))); + } } 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 = [...allNodeItems].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 || ''; + if (previousValue) resetServerConfigurationForms('The selected server is no longer available. Read the replacement server before previewing changes.'); + } + 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.'); + text(commentPreservationState, 'Comment support unknown'); + commentPreservationState.className = 'pill warning'; + 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 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'); + capabilities.forEach(value => { + const pill = text(document.createElement('span'), value); + pill.className = 'pill'; + selectedServerCapabilities.append(pill); + }); +} + +function topologyLink(backend, reporterOnline) { + 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'), !enrollmentsLoaded + ? 'Control enrollment unavailable' + : enrollmentIds.has(backend.backendId) + ? `Enrolled · Control ${registered ? (registered.online ? 'connected' : 'disconnected') : 'not registered'}` + : 'Not enrolled in Control')); + 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')); + } else { + link.append(text(document.createElement('small'), 'Presence not available')); + } + return link; +} + +function renderTopology() { + const proxies = allNodeItems.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, proxy.online))); + } + 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() { + const backendIds = new Set(allNodeItems.filter(isProxy).flatMap(node => + (Array.isArray(node.backends) ? node.backends : []).map(backend => backend.backendId))); + const issueIds = new Set(allNodeItems.filter(node => !node.online).map(node => node.nodeId)); + backendIds.forEach(backendId => { if (!nodeIndex.has(backendId)) issueIds.add(backendId); }); + if (enrollmentsLoaded) { + backendIds.forEach(backendId => { if (!enrollmentIds.has(backendId)) issueIds.add(backendId); }); + } + allNodeItems.filter(node => isProxy(node) && node.online).forEach(proxy => + (Array.isArray(proxy.backends) ? proxy.backends : []).forEach(backend => { + if (backend.presenceKnown && !backend.available) issueIds.add(backend.backendId); + })); + text(metricNodes, allNodeItems.length); + text(metricOnline, allNodeItems.filter(node => node.online).length); + text(metricBackends, backendTopologyTruncated ? `${backendIds.size}+` : backendIds.size); + const issueCount = issueIds.size; + text(metricIssues, backendTopologyTruncated ? `${issueCount}+` : issueCount); +} + +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 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); + resetServerConfigurationForms('Server changed. Read this server before previewing changes.'); + 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; - 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; @@ -212,6 +534,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); @@ -260,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; } @@ -273,11 +629,22 @@ 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 = []; + allNodeItems = []; + enrollmentIds.clear(); + enrollmentsLoaded = false; + backendTopologyTruncated = false; + nodeIndex.clear(); nodeCapabilities.clear(); nodePlugins.clear(); configurationForm.reset(); @@ -292,6 +659,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(); } @@ -347,6 +719,9 @@ async function loadEnrollments() { refreshEnrollments.disabled = true; 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) { enrollmentList.append(text(document.createElement('li'), 'No nodes are enrolled yet.')); @@ -379,6 +754,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; @@ -389,6 +766,46 @@ async function loadEnrollments() { } } +async function loadAllNodes() { + 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() { if (!authenticated) return; refresh.disabled = true; @@ -396,17 +813,14 @@ async function loadNodes() { nextPage.disabled = true; 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))); - } + 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])); 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 => @@ -432,31 +846,55 @@ async function loadNodes() { 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) { + renderServerPicker(); + 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; 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; + 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}`); + 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'); + 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 { @@ -651,6 +1089,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 +1185,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 +1222,8 @@ nextPage.addEventListener('click', () => { }); async function initialize() { await loadHealth(); + setActiveTab(tabFromHash()); + setConfigView('easy'); updateQuickFields(); updatePluginSuggestions(); if (!await loadSetupState()) { diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 4e272c04..c3f41422 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.

+
+ + + +
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..7148be01 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())); @@ -171,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 95374a84..848415e1 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -60,10 +60,25 @@ 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("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()); - 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}")); + 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)")); + 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")); @@ -93,7 +108,17 @@ 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("'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.'")); + 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()); @@ -143,6 +168,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 {