diff --git a/docs/ui-refresh.md b/docs/ui-refresh.md index c484305..aae49e4 100644 --- a/docs/ui-refresh.md +++ b/docs/ui-refresh.md @@ -2,6 +2,8 @@ The interface keeps the existing dark palette and blue accent, with shared controls and task-focused pages. +The follow-up [UI/UX review implementation](ui-ux-implementation.md) records the approved operational, recovery, accessibility, and mobile improvements, with recommendation coverage and validation. + 1. **Shared design system:** common headers, buttons, fields, dialogs, spacing, radii, and focus styles. Removed unused styles from retired layouts. 2. **Libraries:** Plugins opens on the searchable library. Add plugin opens Upload / GitHub tabs. Scripts uses compact status filters; Configs uses a compact summary and Library / Inspect tabs. 3. **Beta setup:** `/betas/new` guides Files → Servers → Test plan → Review. Exact versions, selected servers, and the plan survive Back / Continue. Existing beta selection links still redirect correctly. diff --git a/docs/ui-ux-implementation.md b/docs/ui-ux-implementation.md new file mode 100644 index 0000000..a373795 --- /dev/null +++ b/docs/ui-ux-implementation.md @@ -0,0 +1,57 @@ +# UI/UX review implementation + +Implemented September 19, 2026, following [the approved review](ui-ux-review.md). + +The changes prioritize clear operational outcomes, reviewed action scope, recoverable input, and usable small-screen layouts. Existing server, file, beta, and launch semantics remain visible in the interface rather than being implied by a successful request. + +## Recommendation coverage + +| Review | Implemented behavior | +| --- | --- | +| 1. Stale Fleet data | Stale cards explicitly show **Players unknown**, retain labeled last-known counts and rosters, and stop advancing live durations. Fleet shows snapshot age and refresh/reconnection status. | +| 2. Promotion impact | A promotion review lists exact versions, tested and additional servers, eligibility, staging, and restart policy. The backend checks a review token before writes; changed targets or composed config require a new review. Completion links to the resulting rollout. | +| 3. Reconciliation scope | Selected-server and entire-fleet reviews are separate actions. The plan includes scan time, file operations and restart policy. Execution checks target identities and before/after content fingerprints; changed plans return for review. | +| 4. Beta reading order | Outcome, blockers, deadline, actions, and server results precede evidence and activity. Shared activity history is collapsed, searchable, ordered, and progressively displayed. | +| 5. Beta recovery | Deadlines and pause/retry consequences are explicit. A fresh attempt confirms evidence reset and restarts; expired tests explain rollback and plan reuse. Sign-off availability follows test/run readiness. | +| 6. Launch input retention | Refreshing previews retains entered keys only for unchanged exact server IDs. Changed assignments are explained. Keys remain in memory; managing keys in another tab preserves the launch context. | +| 7. Removal cancellation | Waiting removals can be withdrawn from operation lists/details. A database transition competes atomically with the worker claim before external deletion. Once removal or confirmed-deletion cleanup begins, cancellation is unavailable. | +| 8. Partial outcomes | Reconciliation reports updated and skipped servers separately, with reasons and follow-up links. All-skipped work is an error outcome. Rollback feedback stays beside its initiating operation. | +| 9. Scope clarity | Scope editing previews added/removed eligible servers. Script/plugin unscoping explains retained live copies and tracking detachment; configs explain composition impact. Completion links to reconciliation. Eligibility, promoted versions, and installed inventory have separate labels. | +| 10. Accessible controls | Busy buttons retain their accessible names. Field labels focus the associated native control. Consequential errors use alert semantics; completion uses status semantics. Rollout server states have accessible descriptions. | +| 11. Server investigation | Offline/stale summaries offer Diagnostics and Logs. URL parameters retain the selected tab, RCON diagnosis precedes telemetry history, and removal is under Server actions. | +| 12. Beta drafts | Drafts retain exact versions, label, test plan, selected servers, and wizard step. Resume/discard is explicit, including returns through artifact links. Eligibility is rechecked and compatible selections retained. Unavailable browser storage triggers leave protection. | +| 13. Local validation | Blueprints display total capacity and duplicate-map errors. Beta requirements show field-specific errors and units, expand/focus invalid checks, and validate quorum against the selected cohort. | +| 14. Navigation context | Fleet views and library/operation filters are represented in URLs. Library loaded counts and appropriate selections survive detail navigation and Back. Server and log tabs have direct links. Explicit Fleet URL choices override saved personal defaults. | +| 15. System setup | Settings opens a read-only System status page with configured/unconfigured/unreachable/unverified states, prerequisite instructions, saved-key counts, and first-server guidance. Empty/unconfigured Fleet links directly to setup. | +| 16. Stable config preview | The last successful inspection remains visible during edits, with an updating state. Expanded keys survive refresh. Draft comparisons and ignored-content warnings remain near the editing task. | +| 17. Config composition | Inspection numbers composition layers, identifies winning sources and superseded assignments, and compares draft values with promoted values. Structured editing supports adding a setting without raw syntax. | +| 18. Beta target selection | Files show scope and eligible counts. Conflicts identify incompatible file scopes. Server picking supports search, game/map grouping, health/player context, selected counts and select-visible controls. | +| 19. Evidence interpretation | Requirements explain the next qualifying condition using current observations. Relevant-evidence links apply server/event filters; readable summaries precede raw payloads and telemetry diagnosis is directly linked. | +| 20. Artifact next action | The latest exact version and its test/active-beta action lead the detail page. Technical hashes/sizes are disclosures, version cards remain readable on phones, unscoped game filtering is consistent, and hidden selections are counted. | +| 21. Import continuity | GitHub import shares pending state with its parent dialog, prevents dismissal/tab switches during import, and keeps a completion panel with version/beta links. Script batches show aggregate progress and sticky actions. | +| 22. Key management | Game/map/lobby selectors generate the exact ID, with an expert raw-ID option. Rows show saved time, presence-versus-validation wording, deployment implications, and links to existing servers. Launch forms accept the exact missing/replacement key inline. | +| 23. Blueprint coverage | Existing/queued inventory is distinguished from healthy playable servers, with linked coverage details. Collections group assignments and show a sticky key count and jump-to-missing-key action. Creation remains limited to the target shortfall. | +| 24. Mobile overhead | Fleet avoids redundant headings/zero rollout summaries, empty libraries emphasize their next action, and Leaderboard totals collapse on phones with a compact selected-stat table. Long forms retain reachable actions and focus spacing. | +| 25. Consistent hierarchy | Editable controls use stronger dedicated boundaries. Routes have descriptive titles, operational language is clearer, keyboard shortcuts have a help panel, and selected overflow tabs scroll into view without moving the page. | +| 26. Investigation/handoffs | Logs support literal search/highlighting, copy, tail download, deep links, explicit follow/refresh state and jump-to-latest. A bounded recent-notifications panel retains results in the current browser tab. Beta rows include labels, age and blocker context. | + +The review explicitly made overlay-priority reordering and Leaderboard player history/recent-period views conditional on further design or operator demand. Those product extensions remain future discovery work; the composition explanations and mobile Leaderboard changes are implemented. + +## API and operational boundaries + +- Promotion uses `GET /api/betas/:id/promote` to review impact and `POST` with the returned token as `previewToken` to apply it. A changed plan returns HTTP 409 and needs renewed review. +- Reconciliation uses `POST /api/drift` with `dryRun: true` and optional `servers`, then executes with the returned `expectedFingerprint`. Omitted servers explicitly mean the entire fleet. A stale review returns HTTP 409; changes detected during application are reported as skipped files. +- `POST /api/deployments/:id/cancel` only withdraws a waiting removal. It does not restore a server after deletion has started. +- System status exposes no credential values. Its optional connection check verifies Komodo network reachability, not credential validity. Remote game-file volumes remain explicitly unverified. +- Drafts contain nonsecret beta setup. Launch keys are not stored in browser drafts. Recent notifications use tab-scoped session storage, capped at 50 entries, and do not imply background push delivery. +- Log search/export covers the loaded tail, not an entire remote file or a full diagnostic archive. + +## Validation + +- Full regression suite: **473 tests passed across 44 files**. Added coverage includes changed promotion scope/config, changed reconciliation bytes/targets, cancellation racing a worker, draft parsing/compatibility, scope previews, config comparison and system-status semantics. +- `npm run check`: **0 errors, 0 warnings**. +- `npm run build`: **passed**. The existing SvelteKit CSRF configuration deprecation remains a tooling notice. +- Local browser walkthroughs use an isolated database and synthetic populated API fixtures, with production integration credentials disabled. Fleet, server diagnostics, logs, Settings, Leaderboard, libraries, beta setup/progress, launches, keys, and reconciliation are exercised at desktop and phone widths. +- Browser checks include the loading button accessibility tree, clickable native labels, URL/Back restoration, retained previews/drafts, selected-tab visibility, pending import ownership, and horizontal overflow. These are targeted checks, not a claim of complete accessibility conformance. + +Live Komodo provisioning, real game-server restarts and removals were not executed during verification. Their boundary behavior is covered by regression tests and simulated UI flows. diff --git a/docs/ui-ux-review.md b/docs/ui-ux-review.md new file mode 100644 index 0000000..5b87f5f --- /dev/null +++ b/docs/ui-ux-review.md @@ -0,0 +1,359 @@ +# Electric Cherry UI/UX review + +Reviewed September 19, 2026. + +Implementation follow-up: [completed changes and validation](ui-ux-implementation.md). + +Electric Cherry has a coherent visual foundation and unusually capable operational workflows. The highest-value next investment is making operational decisions easier: what is known, what is waiting, what an action will affect, and how to recover. Preserve the existing dark palette, shared controls, friendly server identities, and guided setup while improving those decisions. + +## Scope and evidence + +This review combines source inspection with a local browser walkthrough. It covers Fleet, server details and diagnostics, Scripts, Configs, Plugins, beta creation and progress, server deployments, blueprints, rollouts, keys, and Leaderboard. Existing design notes and workflow documentation informed the review. + +Browser exploration used an isolated database with Komodo/RCON credentials disabled. Nine top-level pages were examined at 1440×1000 and 390×844. Populated Fleet, server details, and beta scenarios used synthetic API responses. Beta setup, blueprint validation, and the browser accessibility tree were also exercised. Navigation was checked around its responsive breakpoint. Production integrations, actual restarts, and destructive actions were not executed. + +**Evidence labels:** **Rendered** means reproduced in the local browser, sometimes with an explicitly synthetic populated state. **Source** means verified in UI/API implementation. **Opportunity** means a proposed capability or design direction, rather than a defect. Recommendations are expert judgments; they are not findings from end-user interviews or a complete accessibility conformance audit. + +No application code was changed for this review. The accompanying screenshots contain synthetic review data. + +## What to preserve + +- The shared tokens, restrained blue accent, readable text, friendly map/lobby identities, and separate navigation/action targets. +- The existing Fleet search, status filters, pins, card/table views, grouping, live freshness handling in aggregate counts, and lobby watches. +- The four-step beta setup, exact version selection, explicit server opt-in, readable test-plan summaries, and review step. +- Script batch intake, duplicate recognition, partial-success retry, and direct transfer into beta setup. +- Config draft recovery, unsaved-edit protection, effective-setting inspection, and override provenance. +- Deployment previews, shared-queue visibility, and separate recovery actions for health verification and filesystem cleanup. +- Native modal dialogs, keyboard-operated tabs, visible focus, reduced-motion support, retained snapshots after refresh errors, and progressive list rendering. + +Many generic dashboard recommendations are already implemented. The findings below build on them. + +## Recommended order + +P1 means address first because the issue affects trust, consequential actions, lost work, or basic access. P2 means a meaningful workflow improvement. P3 means refinement or a capability to validate with operators. Relative effort is directional: S is a localized UI change; M crosses several components or state flows; L requires a new backend operation or product capability. + +| First work | Recommendation | Priority | Relative effort | +| --- | --- | --- | --- | +| 1 | Label stale player data consistently | P1 | S | +| 2 | Preserve loading-button accessible names | P1 | S | +| 3 | Put beta results and decisions above the event log | P1 | S–M | +| 4 | Preview promotion targets and link the resulting rollout | P1 | M | +| 5 | Make reconciliation scope and partial results explicit | P1 | M | +| 6 | Preserve keys when launch previews refresh | P1 | M | +| 7 | Explain blueprint and test-plan validation locally | P1/P2 | S–M | +| 8 | Surface beta deadlines, reset consequences, and valid recovery actions | P1 | M | + +Cancellation of queued destruction is also important, but needs an explicit backend boundary and should be designed as a separate capability. + +## Recommendations + +### 1. Make stale Fleet cards unmistakable — P1 · Rendered + Source + +Fleet correctly excludes stale servers from live player totals, but passes their old player counts into cards. A stale card can still display a large blue player count, roster, and advancing match duration; its main distinction is an amber status dot and an older “Last seen” timestamp. In the synthetic review, the header reported **3 players online**, while an additional stale card prominently displayed **4**. + +Show **Players unknown** or an em dash as the primary value. Retain historical information under “Last known: 4 players, 3 minutes ago,” with an explicit **Stale** label. Apply the same current-versus-last-known distinction to roster and timing. Add the age of the last successful Fleet refresh and a reconnecting state when appropriate. + +Acceptance: when a fresh server ages into stale status, both card and table stop presenting its previous player count as current. Preserve historical context without requiring interpretation of color. + +Sources: [Fleet page](../src/routes/+page.svelte), lines 56, 62, 208; [ServerCard](../src/lib/ui/components/ServerCard.svelte), lines 72, 114, 150. [Screenshot](ux-review-assets/fleet-stale-count.png). + +### 2. Review the full impact of promotion — P1 · Source + +“Promote” immediately sends a request. The backend expands the tested files to all currently matching scope targets and queues restarts for additional servers. The UI does not show that expansion at the moment of approval. Afterward, it ignores the returned rollout ID. + +Add a concise promotion review with exact versions, tested servers, additional eligible servers, and restart behavior. Use a final label such as **Promote to 12 servers**. Explain eligibility, file staging, and activation separately. After submission, show **Promotion created — 9 servers awaiting activation** with **View rollout**. Keep “Promoted version” distinct from “Running everywhere.” + +Acceptance: an operator who tested one lobby can identify the entire affected set and follow remaining restarts without searching another section. + +Sources: [Beta detail](../src/routes/betas/[id]/+page.svelte), lines 72–79; [promotion implementation](../src/lib/server/fleet/beta.ts), lines 778, 1017, 1060. + +### 3. Make drift reconciliation match the reviewed scope — P1 · Source + +The drift list can be filtered by server, but Reconcile posts an empty body and affects the full fleet. The confirmation includes counts but omits automatic restarts. Execution rescans, so the action can also differ from the displayed scan. + +Separate **Reconcile selected servers** from **Reconcile entire fleet**. Show a file-level plan, scan time, affected servers, and activation policy. If the reviewed plan changes materially, show the updated plan before execution. The API already accepts `servers` and `dryRun`, providing a starting point. + +Acceptance: filtering or selecting one server never leaves the action scope ambiguous. An operator can explain which files will be restored or removed and when servers will restart. + +Sources: [Rollouts page](../src/routes/campaigns/+page.svelte), lines 118, 124, 203, 268; [drift API](../src/routes/api/drift/+server.ts), line 28; [drift implementation](../src/lib/server/fleet/drift.ts), line 265. + +### 4. Put beta outcome and next action above activity — P1 · Rendered + Source + +Beta detail renders up to 25 expanded activity events before files, requirements, per-server progress, and actions. In a synthetic three-server beta with three requirements per server and 25 events: + +| Content starts | Desktop 1440×1000 | Mobile 390×844 | +| --- | ---: | ---: | +| Activity | 436 px | 720 px | +| Test plan/results | 2,623 px | 3,590 px | +| Pending sign-off | 3,528 px | 4,846 px | +| Promote / Rollback | 4,011 px | 5,462 px | + +Place a compact outcome summary first: passed servers, outstanding conditions, deadline, and primary action. Follow with a failed/waiting-first server table. Put activity under a disclosure or separate tab. Keep the useful operation-flow steps, but reduce the oversized started/finished block. + +Acceptance: without scrolling through historical events, an operator can answer “What is blocking this beta, and what can I do?” + +Sources: [Beta detail](../src/routes/betas/[id]/+page.svelte), line 137; [OperationHistory](../src/lib/ui/components/OperationHistory.svelte), lines 21, 46. [Screenshot](ux-review-assets/beta-activity-first.png). + +### 5. Make beta time and recovery rules visible — P1 · Source + +Progress describes a testing window but does not show the actual expiry or time remaining. Waiting for empty servers consumes the window; pausing and retrying retain the original deadline. Starting a fresh attempt resets evidence and causes new restarts. Sign-off controls can appear in states where the API rejects them. + +Show **Ends tomorrow at 14:30 · 6h remaining**, plus **Pausing does not extend this deadline**. Explain evidence reset and restart consequences before a fresh attempt. Offer only valid actions, with a reason for unavailable sign-off. For deadline expiry, explain the required rollback/new-beta sequence and offer a way to reuse the plan. + +Acceptance: paused, failed, inconclusive, and passed tests each have an understandable next step; a retry never silently implies extra testing time. + +Sources: [BetaTestProgress](../src/lib/ui/components/BetaTestProgress.svelte), lines 29, 35, 53; [test action API](../src/routes/api/betas/[id]/test/+server.ts), lines 21, 48–53; [test-plan documentation](beta-test-plans.md). + +### 6. Preserve launch inputs through preview refresh — P1 · Source + +Refreshing a single-server preview clears its entered key. Refreshing a collection clears all entered keys. Preview expiry and capacity changes are normal recovery paths, so users can lose substantial input while doing what the UI requires. + +Keep entered keys in memory, indexed by exact server ID. Reuse a key only when that ID survives the refreshed allocation; flag changed assignments explicitly. Preserve nonsecret launch choices across a visit to Manage keys. Do not persist raw keys into local browser drafts. + +Acceptance: after entering five keys, an unchanged preview refresh retains all five. A new lobby assignment never inherits another server’s key. + +Sources: [DeployServerDialog](../src/lib/ui/components/DeployServerDialog.svelte), lines 41–45, 66, 123; [DeployCollectionDialog](../src/lib/ui/components/DeployCollectionDialog.svelte), line 27. + +### 7. Give queued destructive work a withdrawal window — P1 · Opportunity + +Server removal can wait for occupied or unreachable lobbies. Current detail pages provide resume actions but no cancellation before destruction starts. A delayed command can therefore outlive the operator’s original intention. + +Add **Cancel queued removal** while the operation is still waiting, with a precise backend transition after which cancellation is unavailable. For fleet removal, show per-server waiting/removing/completed states and cancellation eligibility. Clearly distinguish cancelling a request from restoring a server already removed. + +Acceptance: an operator can withdraw a waiting removal, and a race with worker execution cannot leave the UI claiming cancellation after removal has begun. + +Sources: [DestroyServerModal](../src/lib/ui/components/DestroyServerModal.svelte), lines 36–40; [HousePartyDialog](../src/lib/ui/components/HousePartyDialog.svelte), line 51; [deployment detail](../src/routes/deployments/[id]/+page.svelte), lines 110–123. Existing consequence confirmations should remain. + +### 8. Report partial success as a distinct outcome — P1 · Source + +Reconciliation returns per-server skipped reasons, but the UI reads aggregate changed-file counts. Errors and success messages also share a blue-toned flash container. Rollback feedback can appear in the drift card far from the row that initiated it. + +Use structured completion feedback: **7 servers updated, 2 skipped**, with reasons, relevant retry actions, and a rollout link. Place feedback beside the affected task. Keep success, partial success, and failure visually and programmatically distinct. + +Acceptance: a request that skipped every target cannot look like an uneventful successful run; the operator can find the unresolved servers immediately. + +Sources: [Rollouts page](../src/routes/campaigns/+page.svelte), lines 128–138, 181–184, 299, 508; [drift implementation](../src/lib/server/fleet/drift.ts), lines 349, 371, 383–410. + +### 9. Explain scope changes and distinguish eligibility from installation — P1 · Source + +The artifact scope dialog offers selectors and Save. Unscoping scripts/plugins detaches inventory tracking while leaving live copies; unscoping configs changes the intended composed configuration. Both can create reconciliation work. The UI discards the returned detach result for scripts/plugins. The detail label “Applies to” also represents matching scope, not verified installed versions. + +Preview added/removed eligible servers. Explain retained live files when unscoping, show the affected count afterward, and link to the relevant reconciliation view. Determine config impact from composition rather than the script/plugin detach count. Use separate labels for **Eligible servers**, **Promoted version**, and **Installed version**. + +Acceptance: changing eligibility never suggests that running servers have already changed. An operator knows when follow-up reconciliation is required. + +Sources: [ArtifactDetail](../src/lib/ui/components/ArtifactDetail.svelte), lines 183, 298, 380; [scope API](../src/routes/api/scripts/[id]/scope/+server.ts), lines 12, 30; [unscoping behavior](../src/lib/server/fleet/beta.ts), lines 1081–1108. + +### 10. Keep controls named and feedback accessible during actions — P1 · Rendered + Source + +The shared Button hides its label with `visibility: hidden` while its spinner is hidden from assistive technology. Browser accessibility inspection confirmed that **Refresh** becomes an unnamed button while busy. It retains `disabled` and `busy`, but loses its name. + +Preserve the accessible label during loading and announce meaningful progress separately. Audit action failures for alert semantics, and success messages for status semantics. Replace per-server status dots whose explanation exists only in hover text with visible or accessible text. Ensure Field labels focus their associated controls; current label context provides accessible naming, but many visible labels are divs. + +Acceptance: the same action has a stable accessible name before, during, and after execution; keyboard and touch users can identify which server failed without hovering. + +Sources: [Button](../src/lib/ui/components/primitives/Button.svelte), lines 63–71, 155; [Field](../src/lib/ui/components/primitives/Field.svelte), line 25; [rollout mini-pipelines](../src/routes/campaigns/+page.svelte), line 380. These recommendations follow W3C guidance on [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) and [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html). + +### 11. Make server detail lead toward the likely next task — P2 · Rendered + Source + +An offline server still opens on Scoreboard. In the reviewed state this shows “Waiting for game data,” while the only header action is Destroy. The diagnostic view places telemetry request history before the RCON diagnostic, even when connectivity is the immediate issue. + +Keep a stable tab choice, but add an issue summary above it: **RCON has not answered for 1 hour — Diagnose connection · Open logs**. Put removal under a secondary Server actions menu. Allow direct URLs to Logs, Config, and Diagnostics so operation failures can link to the relevant place. Prioritize the diagnostic relevant to the reported failure. + +Acceptance: from an offline Fleet card, the operator can reach the relevant diagnostic in one clear action. Do not automatically switch tabs while someone is reading. + +Sources: [server detail](../src/routes/servers/[name]/+page.svelte), lines 103, 226, 331–337; [PlayerScoreboard](../src/lib/ui/components/PlayerScoreboard.svelte), line 37. [Screenshot](ux-review-assets/offline-server.png). + +### 12. Preserve beta drafts and compatible server selections — P2 · Source + +The wizard preserves Back/Continue state, but navigation or reload loses the draft. Changing or removing a file clears every selected server without explanation. + +Extend the Config editor’s existing draft pattern to beta setup: resume/discard an unfinished draft, retain exact version IDs, and revalidate availability when reopening. Preserve selected servers that still satisfy the new scope intersection; explain any removals. Warn before leaving a dirty draft when recovery is unavailable. + +Acceptance: visiting a file’s detail to check a version does not require rebuilding the test plan and server selection. + +Sources: [beta setup](../src/routes/betas/new/+page.svelte), lines 34, 114, 130, 139; [existing ConfigEditor pattern](../src/lib/ui/components/ConfigEditor.svelte), lines 27–79. + +### 13. Explain why a form cannot proceed — P1/P2 · Rendered + Source + +With six distinct blueprint maps set to 20 lobbies each, both Save actions are disabled with no explanation of the 100-server limit. This was reproduced in the browser. Duplicate maps can also pass client validation and fail on submission. Beta-plan errors can refer to internal names such as `timeoutHours` or an unspecified requirement target. + +Show **120 / 100 target lobbies — reduce the total by 20**. Prevent or merge duplicate maps. Associate errors with the relevant controls, expand a collapsed invalid requirement, and move focus to it. Use target units such as rounds, matches, minutes, or players. Validate quorum against selected servers before final submission. + +Acceptance: every disabled forward/save action has a discoverable reason, and a rejected submission points directly to the field needing correction. + +Sources: [BlueprintBuilder](../src/lib/ui/components/BlueprintBuilder.svelte), lines 18, 81–90; [blueprint validation](../src/lib/server/fleet/launch-recipes.ts), lines 57–63; [BetaPlanBuilder](../src/lib/ui/components/BetaPlanBuilder.svelte), line 123; [beta setup validation](../src/routes/betas/new/+page.svelte), line 68. + +### 14. Preserve task context in URLs and navigation — P2 · Source + +Library filters, sort, loaded count, and selection mostly live in component state. Server detail tabs also default locally. Moving among details and returning can force the operator to rebuild context; another person cannot open the same filtered working view. + +Put filter/sort/view/tab state in URL parameters. Preserve scroll and appropriate selection state across detail navigation. Keep Fleet display preferences in local storage, but distinguish personal defaults from an explicitly shared view. Reuse the Leaderboard’s existing URL-filter pattern. + +Acceptance: browser Back restores the operator’s working set, and a copied Diagnostics or filtered library URL opens the intended view. + +Sources: [Scripts](../src/routes/scripts/+page.svelte), lines 13–23; [Plugins](../src/routes/plugins/+page.svelte), lines 44–49; [ConfigLibrary](../src/lib/ui/components/ConfigLibrary.svelte), lines 24–29; [Leaderboard](../src/routes/leaderboard/+page.svelte). + +### 15. Add a setup and integration-health home — P2 · Opportunity + +Settings currently leads only to server keys. Integration information is available deep in server diagnostics; the empty Fleet encourages creation even when external services are not configured. The first-run user needs a short path from prerequisites to a functioning lobby. + +Add a read-only System status page and a contextual first-run checklist: Komodo configured/reachable, RCON configured, game files available where verifiable, keys available, and first server ready. Link directly to relevant setup instructions. Distinguish **not configured**, **unreachable**, and **server offline**. Avoid repeatedly displaying the full checklist after setup succeeds. + +Acceptance: someone with an empty installation can identify the next missing prerequisite without opening an unrelated server or reading the full README. + +Sources: [navigation](../src/routes/+layout.svelte), lines 80–96; [Fleet empty state](../src/routes/+page.svelte), line 177; [integration store](../src/lib/ui/stores/fleet.ts), line 13; [README setup](../README.md). + +### 16. Keep config previews stable while typing — P2 · Source + +ConfigInspector clears its current inspection immediately on an edit, then waits for its debounce and request. The effective settings disappear while typing, which can disrupt comparison and expanded setting details. + +Retain the last result with **Updating preview…**, replace it when the current request completes, and preserve expanded keys and scroll position. Identify values changed by the draft. Show ignored-content warnings near Save when they affect the submitted config. + +Acceptance: typing several characters does not repeatedly collapse the working preview or shift the editor layout. + +Sources: [ConfigInspector](../src/lib/ui/components/ConfigInspector.svelte), lines 35–54, 75–79; [ConfigTextEditor](../src/lib/ui/components/ConfigTextEditor.svelte). + +### 17. Make config composition easier to reason about — P2 · Opportunity + +The inspector already provides useful provenance, but overlay precedence depends on creation order. Understanding why a setting wins can require understanding historical ordering. + +Extend the existing final-value and winning-source summaries with a numbered composition sequence and per-layer overridden-setting counts. Consider explicit overlay priorities only with a before/after effective-value preview. Add an “Add setting” key/value row to the structured editor, while preserving raw text mode. + +Acceptance: an operator can explain a setting’s final value and add a basic assignment without reverse-engineering creation history or switching to raw syntax unnecessarily. + +Sources: [ConfigInspector](../src/lib/ui/components/ConfigInspector.svelte), lines 79–97; [ConfigTextEditor](../src/lib/ui/components/ConfigTextEditor.svelte), lines 19–35. + +### 18. Make beta compatibility and target selection visible — P2 · Source + +File picking exposes little scope context, while target eligibility is the intersection of all file scopes. An empty intersection produces a generic instruction to choose different scripts. Candidate servers then appear as a flat checkbox collection. + +Show scope and eligible count beside each file/version. Explain concrete conflicts, such as **This Origins script and Town config have no shared servers**. Add target search, map/game grouping, selected count, and current player/health context. Include select-visible for larger fleets while preserving explicit opt-in. + +Acceptance: users can resolve an incompatible bundle without trial and error, and can select a sensible test cohort without scanning every raw entry. + +Sources: [beta setup](../src/routes/betas/new/+page.svelte), lines 159, 237, 276–278. + +### 19. Explain what stalled requirements are waiting for — P2 · Source + +Counters such as `current / target` do not sufficiently distinguish missing telemetry, too few players, a pending game end, or the next report needed to credit gameplay time. Raw evidence JSON leaves that interpretation to the operator. + +Add a next-condition explanation per requirement: **Waiting for a completed match with at least 2 players; last report 4m ago**. Link to the relevant telemetry diagnostic. Summarize evidence in ordinary language, then offer raw JSON. Add server/event filters and “Show evidence for this check.” + +Acceptance: a requirement stuck at zero has a visible explanation and a direct investigation path. + +Sources: [BetaTestProgress](../src/lib/ui/components/BetaTestProgress.svelte), lines 46, 67; [evaluation](../src/lib/server/fleet/beta-evaluation.ts), line 79; [telemetry contract](beta-test-plans.md). + +### 20. Put the next release action at the top of artifact details — P2 · Source + +The header emphasizes Edit scope and Upload new version. Testing is lower in version history, and the “open a beta” link goes to the generic beta list. Version rows emphasize checksum, size, and timestamps; their grid also has fewer explicit columns than some rows have children. + +Add a latest-version summary even before first promotion, with **Test latest version**, **View active beta**, or **Review passed test** as appropriate. Put version, state, change notes, and action first. Move hash and byte size into technical details. Use deliberately labeled mobile version cards. + +Also standardize game-filter behavior for unscoped files across Scripts and Plugins, and show **3 selected · 1 hidden by filters** when selections are concealed. + +Acceptance: after uploading a first version, the next useful action is immediately visible and carries that exact version into setup. + +Sources: [ArtifactDetail](../src/lib/ui/components/ArtifactDetail.svelte), lines 269–278, 312–327, 347–359, 500; [Scripts filters](../src/routes/scripts/+page.svelte), lines 37, 135; [Plugins filters](../src/routes/plugins/+page.svelte), line 51. + +### 21. Own import progress at the dialog level — P2 · Source + +GitHub import tracks busy state inside its child component, while the parent dialog and source tabs only observe file-upload state. The import can continue after dismissal or a tab change, then complete with a navigation or modal-close callback. Import-only success content is immediately hidden when the parent closes. + +Share the pending operation state with the parent. Either keep the import visibly in progress, or allow dismissal with an explicit persistent completion notification. Avoid surprise navigation after the user has moved on. Show **Plugin imported — View version · Start beta** after success. + +For long script batches, add aggregate inspection/upload progress and a sticky action footer while preserving current partial-success retry. + +Acceptance: closing or switching import UI never makes the request’s outcome disappear or causes unexplained later navigation. + +Sources: [Plugins dialog](../src/routes/plugins/+page.svelte), lines 217–220; [PluginGithubImport](../src/lib/ui/components/PluginGithubImport.svelte), lines 18, 60–75, 141; [ScriptUploadDrawer](../src/lib/ui/components/ScriptUploadDrawer.svelte), lines 59, 101, 185. + +### 22. Complete key management within the launch journey — P2 · Source + Opportunity + +The standalone key form asks users to type a machine ID despite existing game/map catalogs. Key rows omit their available update timestamp. “Deployed” indicates that the server exists, not that it uses the latest saved key. + +Generate the exact ID from game/map/lobby selectors, retain a raw-ID option for experts, and link deployed rows to their server. Show **Saved on… · Used on next deployment**. Let the launch flow add or replace its exact missing key without losing launch context. Distinguish a stored key’s presence from credential validation. + +Acceptance: users can add the right key without manually constructing an identifier and can understand whether a running server is affected by an update. + +Sources: [server keys](../src/routes/server-keys/+page.svelte), lines 14, 81–105; [launch dialog](../src/lib/ui/components/DeployServerDialog.svelte), line 123. + +### 23. Separate blueprint coverage from playable capacity — P2 · Source + +A blueprint can be satisfied by existing stopped servers. Its “Existing / queued” identities are exposed through hover text, without an obvious path to inspect them. Meeting an inventory target does not establish that those lobbies are playable. + +Show **3 existing: 1 healthy, 1 stopped, 1 queued** with an expandable linked list. When no creation is needed, offer **Review existing servers**. Retain the current create-only-shortfall semantics, and say explicitly that excess servers remain and existing servers are not updated. + +For collections needing many keys, add a sticky summary, **12 of 20 keys ready**, grouping, and jump-to-next-missing-key. + +Acceptance: “Target satisfied” cannot be mistaken for “All lobbies ready to play.” + +Sources: [DeployCollectionDialog](../src/lib/ui/components/DeployCollectionDialog.svelte), lines 57–59, 72–84. + +### 24. Reduce mobile overhead before useful content — P2 · Rendered + Opportunity + +The inspected mobile pages generally fit the viewport, but some put substantial control and summary space ahead of the task. On the reviewed 390px Fleet, the first server card began around 614px down. The empty Scripts screen still shows zero-count filters, search, sorting, and select-visible controls. Leaderboard rankings begin below its filters and five summary metrics. + +On phones, prioritize one compact health summary, search, and the most useful status filter; keep display options in a small secondary control. Simplify true empty states to the relevant next action. Let Leaderboard summary metrics collapse and offer a compact player/selected-stat presentation while retaining the full scrollable table. Add sticky summaries/actions to long collection and batch forms, checking that they do not cover focused fields. + +Acceptance: a phone user reaches a real server, record, or first-run action quickly, without losing access to advanced controls. Validate sticky behavior with keyboard navigation and zoom, following [W3C focus visibility guidance](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum). + +Sources: [Fleet layout](../src/routes/+page.svelte), lines 180–202, 334; [Scripts](../src/routes/scripts/+page.svelte); [Leaderboard layout](../src/routes/leaderboard/+page.svelte), lines 126–187. + +### 25. Sharpen visual hierarchy and vocabulary consistently — P2/P3 · Rendered + Source + +The visual language is coherent, but small controls, nested cards, repeated section headings, and technical metadata can compete with primary decisions. Input borders use the same very faint token family as passive surfaces. Some routes also retain the generic browser title “Electric Cherry,” making multiple open tabs hard to distinguish. + +Use a dedicated editable-control boundary token, reserve strong surfaces for actionable sections, and reduce repeated title/subtitle layers in builders. Keep body text and important form instructions comfortably readable; use compact metadata selectively. Add descriptive route titles, especially server name and lobby number. + +Use “server” consistently for the managed unit; introduce “lobby” as its player-facing meaning. Prefer **Preview missing servers** over “Conform fleet.” Use “Files” consistently for scripts/configs/plugins where a collective noun is needed. Keep raw IDs, RCON, Komodo, and hashes accessible in technical details. Expose the existing keyboard shortcuts through a small help panel. + +Acceptance: primary tasks are identifiable by placement and wording, and multiple browser tabs are distinguishable. Contrast should be measured before claiming a conformance result. + +Sources: [tokens](../src/lib/ui/styles/tokens.css), lines 57–71, 86–87; [BetaPlanBuilder](../src/lib/ui/components/BetaPlanBuilder.svelte); [navigation and shortcuts](../src/routes/+layout.svelte), lines 37–65; [deployment blueprint actions](../src/routes/deployments/+page.svelte), lines 125–132. + +### 26. Make investigation and handoffs easier over time — P3 · Opportunity + +Logs provide refresh, auto-follow behavior, and a useful tail, but no in-panel search or downloadable investigation bundle. Lobby watches work across navigation, while completed notifications are transient application state and are not a reviewable activity inbox. Beta list rows could also provide more timing and blocker context. + +Add log search/highlighting, copy/download, and a deep link preserving the selected server/log. Make the existing auto-refresh/follow state explicit and offer **Jump to latest**. Consider a small recent-notifications panel for completed watches and actions, without implying background push support. Add beta age, an operator label, and a one-line blocker to list rows. For Leaderboard, investigate player detail/history and recent-period views only after confirming operator demand; existing all-time statistics and counting explanations are a solid base. + +Acceptance: operators can return to a completed notification or share a precise diagnostic view without reconstructing the investigation. + +Sources: [LogPanel](../src/lib/ui/components/LogPanel.svelte); [notification store](../src/lib/ui/toast.ts); [watch handling](../src/lib/ui/stores/fleet.ts); [beta list](../src/routes/betas/+page.svelte); [Leaderboard](../src/routes/leaderboard/+page.svelte). + +## Suggested layout for operational detail pages + +Use the same reading order across beta, rollout, and server-deployment details: + +1. **Outcome and next action:** current state, elapsed/remaining time, blocker, primary recovery or completion action. +2. **Affected servers:** progress counts and a failed/waiting-first list with links to relevant diagnostics. +3. **Change summary:** exact files/versions or deployment recipe, including what is staged versus active. +4. **Evidence and activity:** searchable or collapsible history, with the latest meaningful event summarized near the top. + +This gives the existing shared components a consistent purpose without forcing every operation into identical controls. + +## Practical delivery sequence + +**First pass — clarity and trust:** address stale card values, unnamed loading buttons, silent blueprint limits, beta results placement, and misleading/remote action feedback. These are bounded changes with clear reproduction cases. + +**Second pass — complete action flows:** add promotion and reconciliation reviews, explicit beta timing/retry semantics, retained launch keys, beta drafts, import completion state, and links from outcomes to their rollout or recovery destination. + +**Third pass — scale and polish:** improve target pickers, library context restoration, mobile density, config composition, setup status, and evidence navigation. Design queued-operation cancellation separately with its backend guarantees. + +## Validation plan + +Use a fixture with online, occupied, empty, starting, stale, and unreachable servers; a library large enough to require filtering; an expired preview; an active beta with a long history; and partial deployment failures. Ask representative operators to perform these tasks without narration or hints: + +| Task | Observable success | +| --- | --- | +| Identify a safe empty lobby | User distinguishes confirmed empty from stale/unknown counts. | +| Promote a beta tested on one server | User correctly predicts the entire affected set and restart behavior. | +| Reconcile one selected server | User can name the scope and identify skipped or failed work afterward. | +| Recover a stalled beta | User finds the blocker, deadline, and valid next action without reading the event log. | +| Refresh a five-key launch preview | Unchanged server IDs retain their entered keys; changed IDs are clearly called out. | +| Fix an invalid blueprint | User finds the total/duplicate issue without guessing why Save is disabled. | +| Investigate an offline server on a phone | User reaches the relevant diagnostic promptly and keeps server context. | +| Complete a flow using keyboard/assistive technology | Actions keep names while loading; focus remains visible; errors and completion are announced. | + +Measure completion, errors, unnecessary backtracking, lost input, and time to identify the next action. Establish a baseline before setting numerical improvement targets. Source review and synthetic browser scenarios establish the issues above, but observed operator behavior should guide the final interaction details. diff --git a/docs/ux-review-assets/beta-activity-first.png b/docs/ux-review-assets/beta-activity-first.png new file mode 100644 index 0000000..4e196ba Binary files /dev/null and b/docs/ux-review-assets/beta-activity-first.png differ diff --git a/docs/ux-review-assets/fleet-stale-count.png b/docs/ux-review-assets/fleet-stale-count.png new file mode 100644 index 0000000..b494b3b Binary files /dev/null and b/docs/ux-review-assets/fleet-stale-count.png differ diff --git a/docs/ux-review-assets/offline-server.png b/docs/ux-review-assets/offline-server.png new file mode 100644 index 0000000..75d32e9 Binary files /dev/null and b/docs/ux-review-assets/offline-server.png differ diff --git a/src/lib/beta-draft.ts b/src/lib/beta-draft.ts new file mode 100644 index 0000000..e1ee635 --- /dev/null +++ b/src/lib/beta-draft.ts @@ -0,0 +1,27 @@ +import { requirementKinds, type BetaPlan } from './beta-plan.js'; + +export const betaDraftKey = 'electric-cherry:beta-draft:v1'; +export interface BetaDraft { + version: 1; savedAt: string; step: number; + picks: Array<{ scriptId: number | null; versionId: number | null }>; + servers: string[]; testPlan: BetaPlan; + label?: string; +} +/** Validate draft structure, preserving incomplete numeric fields for correction. */ +export function readBetaDraft(raw: string | null): BetaDraft | null { + if (!raw) return null; + try { + const d = JSON.parse(raw); + if (d?.version !== 1 || !Array.isArray(d.picks) || !d.picks.length || d.picks.length > 100 + || !d.picks.every((p: BetaDraft['picks'][number]) => p && [p.scriptId, p.versionId].every(v => v === null || Number.isSafeInteger(v) && v! > 0)) + || !Array.isArray(d.servers) || !d.servers.every((s: unknown) => typeof s === 'string') + || d.testPlan?.version !== 1 || !Array.isArray(d.testPlan.requirements) || d.testPlan.requirements.length > 30 + || !d.testPlan.requirements.every((r: BetaPlan['requirements'][number]) => r && requirementKinds.includes(r.kind) && typeof r.id === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(r.id) && typeof r.label === 'string')) return null; + return { ...d, step: Math.max(0, Math.min(3, Number(d.step) || 0)) }; + } catch { return null; } +} + +export function compatibleBetaServers(selected: Iterable, candidates: readonly string[]): { kept: Set; removed: string[] } { + const allowed = new Set(candidates), names = [...selected]; + return { kept: new Set(names.filter(name => allowed.has(name))), removed: names.filter(name => !allowed.has(name)) }; +} diff --git a/src/lib/beta-evidence.ts b/src/lib/beta-evidence.ts new file mode 100644 index 0000000..7cb37b7 --- /dev/null +++ b/src/lib/beta-evidence.ts @@ -0,0 +1,37 @@ +import type { BetaRequirement, BetaTestView } from './beta-plan.js'; +import { needsGameTelemetry } from './beta-plan-summary.js'; + +export function betaWaitingCondition(requirement: BetaRequirement, observation?: BetaTestView['servers'][number]['observation']): string { + if (requirement.kind === 'manual') return 'Waiting for a tester to record their name and observation.'; + if (requirement.kind === 'recovery') return 'The other checks must pass before the next restart when empty; each cycle needs fresh evidence.'; + if (requirement.kind === 'healthy-minutes') return observation?.healthy === true + ? 'Collecting consecutive fresh healthy observations; an outage or monitoring gap resets this duration.' + : 'Waiting for fresh container and RCON health observations. Unhealthy or stale observations reset this duration.'; + if (requirement.kind === 'failure-budget') return 'An observed health outage exceeded the budget. Investigate before starting a fresh attempt.'; + if (needsGameTelemetry(requirement)) { + if (!observation?.lastGameplayAt) return 'No gameplay report received for this attempt. Check the game reporter and Telemetry API requests.'; + if (observation.gameEnded) return 'Waiting for the next game and its qualifying reports.'; + if (requirement.kind === 'population') return `Waiting for a game report containing at least ${requirement.target} players.`; + const players = `at least ${requirement.minPlayers} player${requirement.minPlayers === 1 ? '' : 's'}`; + if ((observation.players ?? 0) < requirement.minPlayers) return `The latest report has ${observation.players ?? 0} players; this check needs ${players}.`; + if (requirement.kind === 'matches') return requirement.minMinutes && !observation.matchStarted + ? 'This match has no game-start report, so its duration cannot qualify. Waiting for a new game start and game end.' + : `Waiting for game end${requirement.minRound ? ` after reaching round ${requirement.minRound}` : ''} with ${players}${requirement.minMinutes ? ` and at least ${requirement.minMinutes} minutes since game start` : ''}.`; + if (requirement.kind === 'round') return `Waiting for a round change or game end reporting round ${requirement.target} or higher with ${players}.`; + return `Time is credited when the next report arrives, with ${players} at both ends of the interval. It does not advance continuously between reports.`; + } + return 'Waiting for qualifying evidence.'; +} + +export function betaEvidenceSummary(event: BetaTestView['evidence'][number]): string { + let payload: Record; + try { payload = JSON.parse(event.payload); } catch { return event.kind.replaceAll('-', ' ').replaceAll('_', ' '); } + if (!payload || typeof payload !== 'object') return event.kind; + if (['game_start', 'round_change', 'game_end'].includes(event.kind)) { + const label = event.kind === 'game_start' ? 'Game started' : event.kind === 'game_end' ? 'Game ended' : 'Round changed'; + return `${label}${typeof payload.round === 'number' ? ` at round ${payload.round}` : ''}${Array.isArray(payload.players) ? ` with ${payload.players.length} players` : ''}${typeof payload.map === 'string' ? ` on ${payload.map}` : ''}.`; + } + if (event.kind === 'manual-sign-off') return `${payload.requirement ?? 'Checklist'}: ${payload.note ?? 'Signed off'}`; + if (event.kind === 'health' || event.kind === 'status-sample') return `${payload.healthy ? 'Healthy observation' : 'Unhealthy observation'}${payload.reason ? `: ${payload.reason}` : '.'}`; + return event.kind.replaceAll('-', ' ').replaceAll('_', ' '); +} diff --git a/src/lib/beta-plan.ts b/src/lib/beta-plan.ts index 65bd775..150a987 100644 --- a/src/lib/beta-plan.ts +++ b/src/lib/beta-plan.ts @@ -82,6 +82,30 @@ export interface BetaTestView { plan: BetaPlan; outcome: string; reason: string | null; - servers: Array<{ server: string; phase: number; passed: boolean; progress: RequirementProgress[] }>; + servers: Array<{ server: string; phase: number; passed: boolean; progress: RequirementProgress[]; observingAt?: string | null; + observation?: { lastGameplayAt: string | null; lastEvent: string | null; players: number | null; round: number | null; matchStarted: boolean; gameEnded: boolean; healthy: boolean | null; healthCheckedAt: string | null } }>; evidence: Array<{ id: number; server: string; received_at: string; kind: string; payload: string }>; } + +export interface BetaPlanIssue { field: string; message: string; requirementId?: string } +export function betaPlanIssues(plan: BetaPlan, serverCount?: number): BetaPlanIssue[] { + const issues: BetaPlanIssue[] = []; + const check = (field: string, value: number, min: number, max: number, integer: boolean, message: string, requirementId?: string) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || (integer && !Number.isInteger(value))) issues.push({ field, message, requirementId }); + }; + check('timeoutHours', plan.timeoutHours, .01, 8760, false, 'Enter a testing window between 0.01 and 8,760 hours.'); + check('waveSize', plan.waveSize, 0, 1000, true, 'Enter a whole number from 0 to 1,000; 0 deploys to all selected servers.'); + if (plan.scope === 'quorum') check('quorum', plan.quorum, 1, serverCount ?? 1000, true, `Choose between 1 and ${serverCount ?? 1000} qualifying servers.`); + if (!plan.requirements.length) issues.push({ field: 'requirements', message: 'Add at least one requirement to continue.' }); + for (const r of plan.requirements) { + if (!r.label?.trim()) issues.push({ field: `${r.id}-label`, message: 'Describe what this requirement checks.', requirementId: r.id }); + const integral = ['round', 'matches', 'population', 'failure-budget', 'recovery', 'manual'].includes(r.kind); + check(`${r.id}-target`, r.target, r.kind === 'failure-budget' ? 0 : .000001, 1_000_000, integral, `Enter ${integral ? 'a whole number' : 'a number'} ${r.kind === 'failure-budget' ? 'of zero or more' : 'greater than zero'} for ${requirementLabels[r.kind].toLowerCase()}.`, r.id); + if (['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)) check(`${r.id}-minPlayers`, r.minPlayers, 1, 1_000_000, true, 'Enter at least one player, using a whole number.', r.id); + if (r.kind === 'matches') { + check(`${r.id}-minRound`, r.minRound, 0, 1_000_000, true, 'Enter a whole round number; 0 allows any round.', r.id); + check(`${r.id}-minMinutes`, r.minMinutes, 0, 1_000_000, false, 'Enter a duration of zero or more minutes.', r.id); + } + } + return issues; +} diff --git a/src/lib/beta-promotion.ts b/src/lib/beta-promotion.ts new file mode 100644 index 0000000..b85df3a --- /dev/null +++ b/src/lib/beta-promotion.ts @@ -0,0 +1,14 @@ +export interface BetaPromotionPreview { + betaId: number; + token: string; + createdAt: string; + files: Array<{ + id: number; name: string; kind: string; path: string; versionId: number; + version: number; release_version: string | null; sha256: string; + scope: string; servers: string[]; + }>; + servers: Array<{ name: string; tested: boolean; game: string | null; map: string | null }>; + configWrites: Array<{ server: string; path: string; sha256: string | null; error: string | null }>; + blockers: string[]; + restartPolicy: 'additional-when-empty'; +} diff --git a/src/lib/config-workbench.ts b/src/lib/config-workbench.ts index bc0da8c..2e9919a 100644 --- a/src/lib/config-workbench.ts +++ b/src/lib/config-workbench.ts @@ -45,6 +45,17 @@ export function changeConfigValue(text: string, index: number, value: string): s return parts.join(''); } +/** Add one assignment without changing existing commands or line endings. */ +export function addConfigSetting(text: string, key: string, value: string): string { + key = key.trim(); value = value.trim(); + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) throw new Error('Use a setting name containing letters, numbers, and underscores.'); + if (!value || /[\r\n]/.test(value) || value.startsWith('//')) throw new Error('Enter a value on one line.'); + if (configLines(text).some(entry => entry.key.toLowerCase() === key.toLowerCase())) throw new Error('This setting already exists. Find it above to edit its value.'); + const newline = text.includes('\r\n') ? '\r\n' : '\n'; + const formatted = /\s/.test(value) && !value.startsWith('"') ? JSON.stringify(value) : value; + return text + (text && !text.endsWith('\n') ? newline : '') + `set ${key} ${formatted}` + newline; +} + export function configWarnings(text: string, overlay: boolean): string[] { const warnings: string[] = []; const seen = new Map(); diff --git a/src/lib/deployment-plan.ts b/src/lib/deployment-plan.ts index 05f2618..c9c6572 100644 --- a/src/lib/deployment-plan.ts +++ b/src/lib/deployment-plan.ts @@ -37,7 +37,8 @@ export interface CollectionPreview { expiresAt: string; blueprintName: string; launchConcurrency: number; - entries: { count: number; existing: number; missing: number; existingServers: string[]; recipe: LaunchRecipeSummary }[]; + entries: { count: number; existing: number; missing: number; existingServers: string[]; + coverage: { name: string; health: string; href: string }[]; recipe: LaunchRecipeSummary }[]; servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[]; blockers: string[]; ready: boolean; diff --git a/src/lib/drift-plan.ts b/src/lib/drift-plan.ts new file mode 100644 index 0000000..eea1dd7 --- /dev/null +++ b/src/lib/drift-plan.ts @@ -0,0 +1,26 @@ +export interface DriftPlanFile { + server: string; + relPath: string; + kind: string; + driftKind: 'untracked' | 'missing' | 'replace'; + beforeSha256: string | null; + afterSha256: string | null; + beforeSize: number | null; + afterSize: number | null; +} + +export interface DriftPlan { + fingerprint: string; + scannedAt: string; + servers: string[]; + files: DriftPlanFile[]; +} + +export interface DriftResult { + deploymentId: string | null; + perServer: { server: string; removed: string[]; restored: string[]; skipped: string[] }[]; + totalRemoved: number; + totalRestored: number; + totalServers: number; + dryRun: boolean; +} diff --git a/src/lib/server/fleet/beta-tests.ts b/src/lib/server/fleet/beta-tests.ts index fb08fa0..07e72f4 100644 --- a/src/lib/server/fleet/beta-tests.ts +++ b/src/lib/server/fleet/beta-tests.ts @@ -73,8 +73,15 @@ export function testView(betaId: number): BetaTestView | null { const runs = db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL ORDER BY server').all(betaId) as TestRun[]; return { plan, outcome: stored.outcome, reason: stored.reason, servers: runs.map(run => { - const progress = evaluateRequirements(plan, readMetrics(run), run.phase); - return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress }; + const metrics = readMetrics(run); + const progress = evaluateRequirements(plan, metrics, run.phase); + const latest = db().prepare("SELECT received_at, kind, payload FROM beta_test_evidence WHERE run_id = ? AND kind IN ('game_start','round_change','game_end') ORDER BY id DESC LIMIT 1").get(run.id) as { received_at: string; kind: string; payload: string } | undefined; + const event = latest ? JSON.parse(latest.payload) : null; + return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress, observingAt: run.observing_at, + observation: { lastGameplayAt: latest?.received_at ?? null, lastEvent: latest?.kind ?? null, + players: metrics.match?.players ?? null, round: event?.round ?? null, + matchStarted: metrics.match?.started !== undefined, gameEnded: metrics.match?.ended ?? false, + healthy: metrics.wasHealthy ?? null, healthCheckedAt: metrics.lastSample ? new Date(metrics.lastSample).toISOString() : null } }; }), evidence: db().prepare(`SELECT e.id, r.server, e.received_at, e.kind, e.payload FROM beta_test_evidence e JOIN beta_test_runs r ON r.id = e.run_id WHERE r.beta_id = ? ORDER BY e.id DESC LIMIT 200`).all(betaId) as BetaTestView['evidence'] }; diff --git a/src/lib/server/fleet/beta.ts b/src/lib/server/fleet/beta.ts index 52dace0..d25760a 100644 --- a/src/lib/server/fleet/beta.ts +++ b/src/lib/server/fleet/beta.ts @@ -1,5 +1,6 @@ import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js'; import { storedPlan } from './beta-tests.js'; +import type { BetaPromotionPreview } from '../../beta-promotion.js'; const operations = new Set(); export const betaOperationInFlight = (id: number) => operations.has(id); @@ -192,6 +193,7 @@ export class BetaError extends Error { | 'dest-fail' | 'invalid-state' | 'duplicate-script' + | 'preview-changed' | 'duplicate-target', public readonly details?: unknown ) { @@ -721,6 +723,54 @@ export interface PromoteResult { deploymentId: string; landed: string[]; // unique servers that received at least one file scriptIds: number[]; + awaitingActivation: number; + failedServers: string[]; +} + +/** Snapshot exactly the scope expansion and composed configuration reviewed by the operator. */ +function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configContent: Map } { + const beta = getBeta(betaId); + if (!beta) throw new BetaError('This beta no longer exists.', 'no-such-beta'); + const bundle = listBetaScripts(betaId).map(row => { + const script = getScript(row.script_id), version = getVersion(row.script_version_id); + if (!script || !version) throw new BetaError('A selected file version is no longer available.', 'no-such-version'); + return { script, version }; + }); + if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts'); + const tested = new Set(listBetaServers(betaId).map(row => row.server)); + const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value + ? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort(); + const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind, + path: script.target_rel_path, versionId: version.id, version: version.version, + release_version: version.release_version, sha256: version.sha256, + scope: script.scope_type ? `${script.scope_type}: ${script.scope_value}` : 'Selected test servers only', servers: targets(script) })); + const names = [...new Set(files.flatMap(file => file.servers))].sort(); + const blockers: string[] = []; + const servers = names.map(name => { + const server = getServer(name); + if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`); + return { name, tested: tested.has(name), game: server?.game ?? null, map: server?.map ?? null }; + }); + const collapsing = bundle.filter(({ script }) => isCollapsingKind(script.kind)); + const configContent = new Map(); + const configWrites = [...new Set(collapsing.flatMap(({ script }) => targets(script)))].sort().map(server => { + const row = getServer(server); + const composed = row && composeCfgForServer(server, row.game, row.map, { + inBetaBaseline: collapsing.find(({ script }) => script.kind === 'baseline')?.version, + inBetaOverlays: collapsing.filter(({ script }) => script.kind === 'config') + }); + const error = composed ? null : `${server}: no baseline is available to compose its configuration.`; + if (error) blockers.push(error); + const content = composed ? Buffer.from(composed.text, 'utf8') : null; + if (content) configContent.set(server, content); + return { server, path: configFileOf(server), sha256: content ? sha256Buffer(content) : null, error }; + }); + const impact = { betaId, files, servers, configWrites, blockers, restartPolicy: 'additional-when-empty' as const }; + return { preview: { ...impact, token: sha256Buffer(Buffer.from(JSON.stringify(impact))), createdAt: nowIso() }, configContent }; +} + +export function previewBetaPromotion(betaId: number): BetaPromotionPreview { + return promotionPlan(betaId).preview; } function promoteDeploymentId(betaId: number): string { @@ -728,14 +778,14 @@ function promoteDeploymentId(betaId: number): string { return `dep-${stamp}-promote-${betaId}`; } -export async function promoteBeta(betaId: number): Promise { +export async function promoteBeta(betaId: number, reviewedToken?: string): Promise { if (storedPlan(betaId)) { const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js'); await plannedBetaTick(); } - return exclusive(betaId, () => promoteBetaInternal(betaId)); + return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken)); } -async function promoteBetaInternal(betaId: number): Promise { +async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise { const test = storedPlan(betaId); if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote'); const beta = getBeta(betaId); @@ -743,6 +793,15 @@ async function promoteBetaInternal(betaId: number): Promise { if (beta.state !== 'ready-promote') { throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-ready-promote'); } + const reviewed = promotionPlan(betaId); + const checkReview = () => { + const current = previewBetaPromotion(betaId); + if (current.token !== (reviewedToken ?? reviewed.preview.token)) { + throw new BetaError('Promotion targets or versions changed. Review the updated plan before promoting.', 'preview-changed', current); + } + if (current.blockers.length) throw new BetaError(current.blockers.join(' '), 'invalid-state'); + }; + checkReview(); const bundleRows = listBetaScripts(betaId); if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts'); const bundle = bundleRows.map((br) => ({ @@ -755,8 +814,6 @@ async function promoteBetaInternal(betaId: number): Promise { // Split bundle into non-collapsing (per-file) and collapsing (composed cfg). const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind)); const collapsing = bundle.filter((b) => isCollapsingKind(b.script.kind)); - const collapsingBaseline = collapsing.find((b) => b.script.kind === 'baseline'); - const collapsingOverlays = collapsing.filter((b) => b.script.kind === 'config'); // Build a per-(script, server) plan for non-collapsing artifacts. interface Plan { @@ -776,10 +833,7 @@ async function promoteBetaInternal(betaId: number): Promise { ); for (const { script, version } of nonCollapsing) { - const scopeServers = script.scope_type && script.scope_value - ? serversMatchingScope(script.scope_type, script.scope_value) - : []; - const targets = new Set([...betaServerSet, ...scopeServers]); + const targets = reviewed.preview.files.find(file => file.id === script.id)!.servers; for (const name of targets) { const dest = destOfKind(name, script.kind); @@ -830,23 +884,6 @@ async function promoteBetaInternal(betaId: number): Promise { // Write files. Track per-plan success and per-server first-error. const planWritten = new Map(); const serverErr = new Map(); - for (const p of plans) { - const dest = destOfKind(p.server, p.script.kind); - const targetPath = join(dest, p.script.target_rel_path); - try { - await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true }); - // Beta servers already have the version bytes at targetPath from - // openBeta; scope-expansion servers need the write. Idempotent - // either way — we just overwrite with the promoted content. - await writeFile(targetPath, p.version.content); - planWritten.set(p, true); - } catch (err) { - planWritten.set(p, false); - const msg = `${p.script.name}: ${(err as Error).message}`; - if (!serverErr.has(p.server)) serverErr.set(p.server, msg); - console.error(`[promote] ${p.server}/${p.script.name} failed:`, err); - } - } // Collapsing config layer: one composed cfg per server. Union of beta // servers + scope-matching servers for every collapsing artifact. @@ -860,29 +897,18 @@ async function promoteBetaInternal(betaId: number): Promise { } const cfgPlans: CfgPlan[] = []; if (collapsing.length > 0) { - const cfgTargets = new Set(betaServerSet); - for (const { script } of collapsing) { - if (script.scope_type && script.scope_value) { - for (const s of serversMatchingScope(script.scope_type, script.scope_value)) { - cfgTargets.add(s); - } - } - } + const cfgTargets = reviewed.preview.configWrites.map(write => write.server); for (const name of cfgTargets) { const srv = getServer(name); if (!srv) { serverErr.set(name, 'unknown server during config promote'); continue; } - const composedResult = composeCfgForServer(name, srv.game, srv.map, { - inBetaBaseline: collapsingBaseline?.version, - inBetaOverlays: collapsingOverlays - }); - if (!composedResult) { + const composedBuf = reviewed.configContent.get(name); + if (!composedBuf) { serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`); continue; } - const composedBuf = Buffer.from(composedResult.text, 'utf8'); const composedSha = sha256Buffer(composedBuf); const dest = destOfKind(name, 'config'); const targetRel = configFileOf(name); @@ -913,6 +939,20 @@ async function promoteBetaInternal(betaId: number): Promise { } } + // Recheck after asynchronous snapshot reads, before any mutation. Execution uses + // the captured target lists/content so later membership changes never expand it. + checkReview(); + for (const p of plans) { + const dest = destOfKind(p.server, p.script.kind); + try { + await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true }); + await writeFile(join(dest, p.script.target_rel_path), p.version.content); + planWritten.set(p, true); + } catch (err) { + planWritten.set(p, false); + if (!serverErr.has(p.server)) serverErr.set(p.server, `${p.script.name}: ${(err as Error).message}`); + } + } // Write composed cfg to each target. const cfgWritten = new Map(); for (const cp of cfgPlans) { @@ -1061,7 +1101,9 @@ async function promoteBetaInternal(betaId: number): Promise { betaId, deploymentId: depId, landed, - scriptIds: bundle.map((b) => b.script.id) + scriptIds: bundle.map((b) => b.script.id), + awaitingActivation: reviewed.preview.servers.filter(server => !server.tested && !serverErr.has(server.name)).length, + failedServers: [...serverErr.keys()] }; } diff --git a/src/lib/server/fleet/collections.ts b/src/lib/server/fleet/collections.ts index 9434686..93331ae 100644 --- a/src/lib/server/fleet/collections.ts +++ b/src/lib/server/fleet/collections.ts @@ -5,6 +5,8 @@ import { config, komodoConfigured, rconConfigured } from '../config.js'; import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js'; import { creationQueue, nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js'; import { getServerKey } from './server-keys.js'; +import { classifyHealth } from './health.js'; +import { getServer } from './servers.js'; import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js'; function coverage(entries: BlueprintEntry[]) { @@ -29,7 +31,15 @@ function coverage(entries: BlueprintEntry[]) { const matches = zombieMaps(game).filter(map => map.canonical === server.map); return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug; }).map(server => server.name).sort(); - return { existingServers, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) }; + const coverage = existingServers.map(name => { + const operation = db().prepare("SELECT id, state FROM server_deployments WHERE server = ? AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health') ORDER BY rowid DESC LIMIT 1").get(name) as { id: string; state: string } | undefined; + const server = getServer(name); + return { name, health: operation ? operation.state === 'queued' ? 'queued' : 'starting' + : server?.container_state && !['running', 'starting', 'restarting'].includes(server.container_state) ? 'stopped' + : classifyHealth(server?.last_seen_at ?? null, server?.last_error ?? null, server?.container_state), + href: operation ? `/deployments/${encodeURIComponent(operation.id)}` : `/servers/${encodeURIComponent(name)}` }; + }); + return { existingServers, coverage, existing: existingServers.length, missing: Math.max(0, entry.count - existingServers.length) }; }); } diff --git a/src/lib/server/fleet/create.ts b/src/lib/server/fleet/create.ts index 9a980bf..b3857db 100644 --- a/src/lib/server/fleet/create.ts +++ b/src/lib/server/fleet/create.ts @@ -1,5 +1,5 @@ import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js'; -import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification } from './deployments.js'; +import { beginDeployment, deploymentStage, finishDeployment, recordDeploymentEvent, REMOVING_SERVER, resumeCleanup, resumeVerification, claimRemoval } from './deployments.js'; import { randomUUID } from 'node:crypto'; import type { LaunchPreview } from '$lib/deployment-plan.js'; import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js'; @@ -461,6 +461,7 @@ async function runDestructions(): Promise { const queued = db().prepare("SELECT id, server, stage FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string; stage: string }[]; for (const operation of queued) { if (creating || destroying.has(operation.server)) continue; + if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue; destroying.add(operation.server); try { if (operation.stage === REMOVING_SERVER) { @@ -470,7 +471,7 @@ async function runDestructions(): Promise { finishDeployment(operation.id, { ok: false, message: 'A newer creation exists; local cleanup cannot safely resume.' }); continue; } - db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id); + if (!claimRemoval(operation.id)) continue; finishDeployment(operation.id, await cleanupDestroyedServer(operation.server, operation.id)); continue; } @@ -484,20 +485,21 @@ async function runDestructions(): Promise { } // A fresh successful zero is required; cached occupancy and timeouts are not evidence of emptiness. const players = await rconGetPlayerCount(operation.server); + if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue; if (players !== 0) { deploymentStage(operation.id, players === null ? 'Waiting for player count: server could not be reached' : 'Waiting for server to empty: ' + players + ' player(s) online'); continue; } + if (!claimRemoval(operation.id)) continue; deploymentStage(operation.id, 'Server is empty (0 players verified)'); - db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(operation.id); const result = await destroyServerUnlocked(operation.server, operation.id); finishDeployment(operation.id, result); } catch (err) { const row = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(operation.id) as { state: string }; if (row.state === 'waiting') deploymentStage(operation.id, 'Waiting for player count: ' + (err as Error).message); - else finishDeployment(operation.id, { ok: false, message: (err as Error).message }); + else if (row.state !== 'cancelled') finishDeployment(operation.id, { ok: false, message: (err as Error).message }); } finally { destroying.delete(operation.server); } } } diff --git a/src/lib/server/fleet/deployments.ts b/src/lib/server/fleet/deployments.ts index 7226401..3f19825 100644 --- a/src/lib/server/fleet/deployments.ts +++ b/src/lib/server/fleet/deployments.ts @@ -30,12 +30,31 @@ export function getDeployment(id: string) { const collection = db().prepare(`SELECT c.id, c.name FROM server_collection_members m JOIN server_launch_collections c ON c.id = m.collection_id WHERE m.deployment_id = ?`).get(id) ?? null; return { deployment, events, recipe: launch ? summarizeRecipe(launch.recipe) : null, blueprintName: launch?.blueprintName ?? null, - canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), collection }; + canResumeVerification: canResumeVerification(id), canResumeCleanup: canResumeCleanup(id), canCancelRemoval: canCancelRemoval(id), collection }; } // This stage is written only after Komodo has confirmed deployment deletion. export const REMOVING_SERVER = 'Removing server files and registry'; +export function canCancelRemoval(id: string): boolean { + return !!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?").get(id, REMOVING_SERVER); +} + +/** Competes atomically with the worker's claim, before any external removal. */ +export function cancelRemoval(id: string): boolean { + return db().transaction(() => { + const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL + WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(new Date().toISOString(), id, REMOVING_SERVER); + if (!result.changes) return false; + recordDeploymentEvent(id, 'Queued removal cancelled. The server and its files were kept.'); + return true; + })(); +} + +export function claimRemoval(id: string): boolean { + return db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND action = 'destroy' AND state = 'waiting'").run(id).changes > 0; +} + export function canResumeCleanup(id: string): boolean { return !!db().prepare(`SELECT d.id FROM server_deployments d WHERE d.id = ? AND d.action = 'destroy' AND d.state IN ('failed', 'interrupted') AND d.stage = ? @@ -81,8 +100,9 @@ export function finishDeployment(id: string, result: { ok: boolean; message?: st } export function listDeployments() { - return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists - FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(); + return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists, + (d.action = 'destroy' AND d.state = 'waiting' AND d.stage != ?) AS canCancelRemoval + FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(REMOVING_SERVER); } export function interruptDeployments(): void { diff --git a/src/lib/server/fleet/drift.ts b/src/lib/server/fleet/drift.ts index 25c42bb..52acf37 100644 --- a/src/lib/server/fleet/drift.ts +++ b/src/lib/server/fleet/drift.ts @@ -1,4 +1,6 @@ import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js'; import { existsSync } from 'node:fs'; import { dirname, join, posix, relative, sep } from 'node:path'; import { configFileOf, destOfKind, type ArtifactKind } from '../config.js'; @@ -244,6 +246,40 @@ export interface RunDriftInput { servers?: string[]; // if omitted, scans the whole fleet dryRun?: boolean; triggeredBy?: 'ui' | 'sweep' | 'cron'; + expectedFingerprint?: string; +} + +export class DriftPlanChanged extends Error { + constructor(public plan: DriftPlan) { super('The files or target servers changed. Nothing was changed. Review the updated plan before continuing.'); } +} + +function driftTargets(servers?: string[]): string[] { + const registered = listServers().map(server => server.name); + if (servers && (!servers.length || servers.some(name => !registered.includes(name)))) throw new Error('Select at least one registered server. Refresh the fleet if a server was removed.'); + return [...new Set(servers ?? registered)].sort(); +} + +async function describePlan(servers: string[], entries: DriftEntry[]): Promise { + const files: DriftPlanFile[] = []; + for (const entry of entries) { + const before = await readFile(join(destOfKind(entry.server, entry.kind), entry.relPath)).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + const after = entry.driftKind === 'untracked' ? null : await resolveManagedEntry(entry.server, entry); + files.push({ server: entry.server, relPath: entry.relPath, kind: entry.kind, driftKind: entry.driftKind, + beforeSha256: before ? sha256Buffer(before) : null, afterSha256: after ? sha256Buffer(after) : null, + beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null }); + } + files.sort((a, b) => `${a.server}:${a.kind}:${a.relPath}`.localeCompare(`${b.server}:${b.kind}:${b.relPath}`)); + return { servers, files, scannedAt: new Date().toISOString(), fingerprint: sha256Buffer(Buffer.from(JSON.stringify({ servers, files }))) }; +} + +export async function previewDrift(servers?: string[]): Promise { + const targets = driftTargets(servers); + const entries: DriftEntry[] = []; + for (const server of targets) entries.push(...await scanServerDrift(server)); + return describePlan(targets, entries); } export interface RunDriftResult { @@ -259,7 +295,7 @@ export interface RunDriftResult { function deploymentIdNow(): string { const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z'); - return `dep-${stamp}-drift`; + return `dep-${stamp}-drift-${randomUUID().slice(0, 8)}`; } /** @@ -271,9 +307,7 @@ function deploymentIdNow(): string { export async function runDriftDeployment(input: RunDriftInput = {}): Promise { const { dryRun = false, triggeredBy = 'ui' } = input; - const scanTargets = input.servers && input.servers.length > 0 - ? input.servers - : listServers().map((s) => s.name); + const scanTargets = driftTargets(input.servers); const perServer: Array<{ server: string; @@ -287,6 +321,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise server.entries)) : null; + if (plan && plan.fingerprint !== input.expectedFingerprint) throw new DriftPlanChanged(plan); const totalDrift = perServer.reduce((sum, s) => sum + s.entries.length, 0); if (totalDrift === 0 || dryRun) { return { @@ -335,6 +372,14 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise file.server === s.server && file.kind === entry.kind && file.relPath === entry.relPath); + if (reviewed) { + const current = await readFile(abs).catch((error: NodeJS.ErrnoException) => { if (error.code === 'ENOENT') return null; throw error; }); + if ((current ? sha256Buffer(current) : null) !== reviewed.beforeSha256) { + s.skipped.push(`${entry.relPath}: changed after review; scan and review again`); + continue; + } + } if (entry.driftKind === 'untracked') { // Delete a file inventory doesn't know about. const content = await readFile(abs); @@ -349,6 +394,10 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise { if (err.code === 'ENOENT') return null; throw err; diff --git a/src/lib/server/fleet/overview.ts b/src/lib/server/fleet/overview.ts index 747ef03..4248491 100644 --- a/src/lib/server/fleet/overview.ts +++ b/src/lib/server/fleet/overview.ts @@ -35,17 +35,17 @@ export function fleetOperations(now = Date.now()): FleetOperation[] { // Only the newest lifecycle operation can still require action on a server. const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d WHERE d.state IN ('queued', 'running', 'waiting', 'awaiting-health') - OR (d.state IN ('done', 'warning') AND d.finished_at >= ?) + OR (d.state IN ('done', 'warning', 'cancelled') AND d.finished_at >= ?) OR (d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS (SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid))`) .all(since) as { id: string; server: string; action: string; state: string; stage: string; error: string | null; started_at: string }[]; for (const row of lifecycle) { - const state = row.state === 'done' || row.state === 'warning' ? 'done' + const state = ['done', 'warning', 'cancelled'].includes(row.state) ? 'done' : ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active'; operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment', server: row.server, state, startedAt: row.started_at, - label: state === 'done' ? row.state === 'warning' ? 'Completed with warnings' : 'Complete' + label: state === 'done' ? row.state === 'cancelled' ? 'Removal cancelled' : row.state === 'warning' ? 'Completed with warnings' : 'Complete' : state === 'attention' ? `${row.action === 'create' ? 'Launch' : 'Removal'} needs attention` : row.stage, detail: row.error ?? row.stage, href: `/deployments/${encodeURIComponent(row.id)}` }); } diff --git a/src/lib/system-status.ts b/src/lib/system-status.ts new file mode 100644 index 0000000..0645195 --- /dev/null +++ b/src/lib/system-status.ts @@ -0,0 +1,15 @@ +export interface SetupCheck { + id: string; + title: string; + state: 'ready' | 'attention' | 'unconfigured' | 'unverified'; + detail: string; + href?: string; + action?: string; +} +export interface SystemStatus { + checkedAt: string; + checks: SetupCheck[]; + readyServers: number; + totalServers: number; + savedKeys: number; +} diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index a9b37d4..5c79f0c 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -50,6 +50,13 @@ let artifact = $state(null); let versions = $state([]); let matchedServers = $state([]); + let installedVersions = $state>([]); + interface ScopeImpact { before: string[]; after: string[]; added: string[]; removed: string[]; affected: string[]; composed: boolean; detached: string[]; } + let scopeReviewOpen = $state(false); + let scopeImpact = $state(null); + let scopePreviewError = $state(''); + let scopePreviewLoading = $state(false); + let scopeComplete = $state<{ count: number; composed: boolean; detached: number; href: string } | null>(null); let runningBetaId = $state(null); let loading = $state(true); let flash = $state(null); @@ -84,9 +91,24 @@ : null ); const latestVersion = $derived(versions[0] ?? null); - const showLatestSeparately = $derived( - latestVersion !== null && currentVersion !== null && latestVersion.id !== currentVersion.id - ); + + $effect(() => { + const type = scopeType; const value = editedScopeValue; const artifactId = id; + if (!scopeReviewOpen) return; + scopeImpact = null; scopePreviewError = ''; + if (type !== 'none' && !value) { scopePreviewLoading = false; return; } + const controller = new AbortController(); scopePreviewLoading = true; + void (async () => { + try { + const response = await fetch(`/api/scripts/${artifactId}/scope?${new URLSearchParams({ type, value })}`, { signal: controller.signal }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error ?? 'Could not preview scope.'); + if (!controller.signal.aborted) scopeImpact = body; + } catch (error) { if (!controller.signal.aborted) scopePreviewError = (error as Error).message; } + finally { if (!controller.signal.aborted) scopePreviewLoading = false; } + })(); + return () => controller.abort(); + }); function scopeLabel(s: Artifact): string { if (!s.scope_type) return 'unscoped'; @@ -105,7 +127,7 @@ const body = await detailRes.json(); const scopes = scopeRes?.ok ? await scopeRes.json() : null; if (requestedId !== id || current !== request) return; - artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId; + artifact = body.script; versions = body.versions; matchedServers = body.matchedServers; runningBetaId = body.runningBetaId; installedVersions = body.installedVersions ?? []; if (scopes) scopeOptions = scopes; if (seedForm) seedScopeForm(); loadError = ''; @@ -135,10 +157,12 @@ if (runningBetaId !== null) return; seedScopeForm(); flash = null; + scopeReviewOpen = true; scopeDialog?.showModal(); } function cancelScopeModal(): void { seedScopeForm(); + scopeReviewOpen = false; scopeDialog?.close(); } function openUploadModal(): void { @@ -192,7 +216,7 @@ async function saveScope(e: Event): Promise { e.preventDefault(); - if (updatingScope) return; + if (updatingScope || !scopeImpact || scopePreviewLoading) return; if (scopeType !== 'none' && !editedScopeValue) { flash = `Pick a ${scopeType}`; return; @@ -207,12 +231,13 @@ scopeValue: scopeType === 'none' ? null : editedScopeValue }) }); - const body = (await res.json()) as { ok: boolean; error?: string; kind?: string }; + const body = (await res.json()) as { ok: boolean; error?: string; kind?: string; detached?: string[] }; if (!res.ok || !body.ok) { flash = body.error ?? `HTTP ${res.status}`; return; } - scopeDialog?.close(); + scopeComplete = { count: scopeImpact.affected.length, composed: scopeImpact.composed, detached: body.detached?.length ?? 0, href: scopeImpact.affected.length === 1 ? '/campaigns?server=' + encodeURIComponent(scopeImpact.affected[0]) : '/campaigns' }; + scopeReviewOpen = false; scopeDialog?.close(); await load(); } catch (error) { flash = (error as Error).message; } finally { updatingScope = false; @@ -244,6 +269,8 @@ ]; +{artifact?.name ?? crumbLabel} · Electric Cherry + {#if loadError}{/if} {#if loading}
Loading…
@@ -258,6 +285,9 @@ {/snippet} + {#if scopeComplete}
Scope saved. {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Running servers have not been changed. Review reconciliation →
{/if} + {#if latestVersion}

Latest {artifactVersionLabel(latestVersion)}

{latestVersion.notes || 'No version notes provided.'}

{#if runningBetaId !== null}{:else if latestVersion.id !== a.current_version_id}{:else}Latest version is promoted{/if}
{/if} + {#if runningBetaId}
Beta #{runningBetaId} is running for this {family}. @@ -278,7 +308,7 @@
-
+
Target file, scope, and eligible servers
Target file {a.target_rel_path} @@ -295,7 +325,7 @@
- Applies to + Eligible servers {#if !a.scope_type} Unscoped — deploy from a beta @@ -307,28 +337,21 @@
- Current version + Promoted version {#if currentVersion} {artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(0, 8)} Promoted {:else} - Never promoted — open a beta + Never promoted — test a version first {/if}
- {#if showLatestSeparately && latestVersion} -
- Latest version - - {artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)} - - -
- {/if} -
+
+ {#if installedVersions.length}
Recorded installed files · {installedVersions.length} servers

Last recorded file writes, not a verification of the currently running process.

    {#each installedVersions as installed}
  • {installed.server} · v{installed.version} · {new Date(installed.recordedAt).toLocaleString()}
  • {/each}
{:else if family !== 'config'}

No installed file versions recorded. Eligibility does not mean a file is installed.

{/if} + {#if a.kind === 'config' || a.kind === 'baseline'} load({ seedForm: false })} /> @@ -346,8 +369,6 @@ {#each versions.slice(0,limit) as v (v.id)}
{artifactVersionLabel(v)} - {v.sha256.slice(0, 12)} - {v.size} B {v.notes ?? ''} {#if v.source}GitHub · {v.source.asset_name} ↗{/if} @@ -358,6 +379,7 @@ {#if runningBetaId === null}{/if} {/if} +
File detailsSHA-256: {v.sha256}Size: {v.size.toLocaleString()} bytes
{/each} @@ -374,10 +396,10 @@ - {#if flash}
{flash}
{/if} + {#if flash}{/if} - { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}> + scopeReviewOpen = false} oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>

Edit scope

@@ -387,20 +409,21 @@
+ {/if}
+ {#if scopePreviewLoading}

Reviewing eligible servers…

{:else if scopePreviewError}

{scopePreviewError}

{:else if scopeImpact}
{scopeImpact.after.length} eligible · {scopeImpact.added.length} added · {scopeImpact.removed.length} removed{#if scopeImpact.added.length}

Added: {scopeImpact.added.join(', ')}

{/if}{#if scopeImpact.removed.length}

Removed: {scopeImpact.removed.join(', ')}

{/if}

{scopeImpact.composed ? 'This changes intended configuration composition. Live configuration files remain unchanged until a deployment or reconciliation.' : scopeType === 'none' ? `${scopeImpact.detached.length} recorded installations will become untracked. Files remain on servers; review reconciliation to remove orphaned files.` : 'Scope describes eligibility. Saving does not install files or restart servers; review reconciliation for existing promoted files.'}

{/if}
-
- {#if flash}
{flash}
{/if} + {#if flash}{/if}
@@ -433,7 +456,7 @@ Upload version - {#if flash}
{flash}
{/if} + {#if flash}{/if}
@@ -499,7 +522,7 @@ .version-list { display: flex; flex-direction: column; gap: var(--space-1); } .version-row { display: grid; - grid-template-columns: minmax(100px, 1fr) 130px 80px 1fr 220px auto; + grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto auto auto; gap: var(--space-3); align-items: center; padding: var(--space-2) var(--space-3); @@ -514,7 +537,17 @@ background: rgba(124, 212, 255, 0.04); } .v-num { color: var(--text-primary); font-weight: var(--weight-semibold); overflow-wrap: anywhere; } - .v-sha, .v-size, .v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; } + .v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; } + .version-technical { grid-column: 1 / -1; color: var(--text-secondary); overflow-wrap: anywhere; } + .version-technical span { display: block; margin-top: 8px; } + .scope-complete { padding: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); font-size: var(--text-sm); } + .release-summary { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; } + .release-summary h2 { font-size: var(--text-lg); margin: 0 0 8px; } + .release-summary p, .installed p, .scope-impact p { color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; } + .scope-impact { border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); } + .installed { font-size: var(--text-sm); } + .file-settings { font-size: var(--text-sm); } + .file-settings .kv-grid { margin-top: 16px; } .v-notes { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-sm); } .v-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow-y: auto; } .v-notes a { display: block; color: var(--accent-primary-hover); margin-top: var(--space-2); } diff --git a/src/lib/ui/components/BetaPlanBuilder.svelte b/src/lib/ui/components/BetaPlanBuilder.svelte index 824e28b..bc4dc4d 100644 --- a/src/lib/ui/components/BetaPlanBuilder.svelte +++ b/src/lib/ui/components/BetaPlanBuilder.svelte @@ -1,9 +1,23 @@
-

Test plan

- {#if test.reason}

{test.reason}

{/if} -

{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion

-
Test policy

{test.plan.waveSize ? test.plan.waveSize + ' servers per wave.' : 'All servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause the test for investigation.' : 'Failed checks hold the test for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; servers must be restarted manually.'}

+

Test results

+

{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.

{#if !closed} -
- {#if test.outcome === 'paused'}{/if} - {#if ['active', 'passed'].includes(test.outcome)}{/if} - {#if ['paused', 'failed'].includes(test.outcome)}{/if} -
+
+ {#if test.outcome === 'paused' && !expired}{/if} + {#if ['active','passed'].includes(test.outcome)}{/if} + {#if ['paused','failed'].includes(test.outcome) && !expired}{/if} +
+ {#if retryBlocked && ['paused','failed'].includes(test.outcome)}

{retryBlocked}

{/if} + {#if expired}

The testing window ended. Roll back this beta first, then choose “Reuse files and test plan” to open a new beta with a longer window.

+ {:else if test.outcome === 'paused'}

Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.

{/if} {/if} {#if error}{/if} - {#if test.servers.length === 0}

Waiting for the first server restart. Evidence collection begins with that run.

{/if} - {#each test.servers as server (server.server)} -
-

Recovery cycles: {server.phase}

- {#each server.progress as progress (progress.id)} - {@const requirement = test.plan.requirements.find(r => r.id === progress.id)!} -
-
{progress.passed ? '✓' : progress.failed ? '✕' : '○'} {progress.label}{progress.detail}
- {#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(requirement.kind)} - Minimum {requirement.minPlayers} player(s){requirement.minRound ? `, round ${requirement.minRound} in the same game` : ''}{requirement.minMinutes ? `, ${requirement.minMinutes} minutes per match` : ''} - {/if} - {#if !['manual', 'failure-budget'].includes(requirement.kind)} - - {/if} - {#if requirement.kind === 'manual' && !progress.passed && !closed} -
- - -
- {/if} -
- {/each} -
- {/each} -
evidenceOpen = event.currentTarget.open}> - Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''}) - {#if evidenceOpen} - {#each test.evidence.slice(0,evidenceLimit) as event (event.id)} -
{event.server} · {event.kind}
{event.payload}
+ {#if message}

{message}

{/if} + {#if !test.servers.length}

Waiting for the first server restart. Evidence collection begins with that run. The testing window is already running.

{/if} + {#each ranked as server (server.server)} +
+ {server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed{#if test.plan.requirements.some(r=>r.kind==='recovery')}Recovery cycles: {server.phase}{/if} + {#if test.plan.requirements.some(needsGameTelemetry)}

{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} Telemetry diagnostics

{/if} + {#each server.progress as progress (progress.id)} + {@const requirement = test.plan.requirements.find(r=>r.id===progress.id)} + {#if requirement}
+
{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'} {requirementSummary(requirement)}{progress.detail}
+ {#if !progress.passed && !closed}

{betaWaitingCondition(requirement,server.observation)}

{/if} + {#if !['manual','failure-budget'].includes(requirement.kind)}{/if} + {#if requirement.kind === 'manual' && !progress.passed && !closed} + {#if canSign && server.observingAt}
+ {:else if canSign}

Waiting for this server's restart and health verification to finish before tester sign-off.

+ {:else}

Sign-off is unavailable while this test is {test.outcome}. {test.outcome === 'paused' ? 'Resume testing first.' : 'Use the recovery instructions above.'}

{/if} + {/if} + +
{/if} {/each} - - {#if !test.evidence.length}

No evidence recorded yet.

{/if} +
+ {/each} +
Test policy

{test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All selected servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold testing for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; restart servers manually.'} Health checks continue after passing until promotion.

+
+ Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''}) + {#if evidenceOpen} +

Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.

+
+ {#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}

{betaEvidenceSummary(event)}

Raw payload · {event.kind}
{event.payload}
{/each} + + {#if !filteredEvidence.length}

{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}

{/if} {/if}
diff --git a/src/lib/ui/components/BlueprintBuilder.svelte b/src/lib/ui/components/BlueprintBuilder.svelte index 4254c2f..a215b22 100644 --- a/src/lib/ui/components/BlueprintBuilder.svelte +++ b/src/lib/ui/components/BlueprintBuilder.svelte @@ -15,7 +15,10 @@ let busy = $state(false); let error = $state(''); let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0)); - let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20)); + const duplicate = (row: typeof rows[number]) => rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === row.mapSlug); + let valid = $derived(name.trim().length > 0 && rows.length > 0 && total <= 100 && rows.every(row => row.mapSlug && Number.isInteger(row.count) && row.count >= 1 && row.count <= 20 && !duplicate(row))); + let reason = $derived(!name.trim() ? 'Enter a blueprint name to save.' : total > 100 ? `Reduce the total by ${total - 100} to stay within 100 target lobbies.` : rows.some(duplicate) ? 'Choose each game and map once; increase its lobby count instead.' : !valid ? 'Choose a map and 1–20 target lobbies for every row.' : ''); + let canAdd = $derived((['t5', 't6'] as const).some(game => catalogs[game].some(map => !rows.some(row => row.game === game && row.mapSlug === map.slug)))); let wasOpen = false; let session = 0; let nextKey = 0; @@ -74,19 +77,20 @@
{ event.preventDefault(); void save(true); }}>
-

Your lineup

{total} server{total === 1 ? '' : 's'}
+

Your lineup

100} role="status">{total} / 100 target lobbies
{#each rows as row, index (row.key)}
{String(index + 1).padStart(2, '0')} - - + +
{/each} - +

Blueprints save your maps and target lobby counts. Every new server uses the latest promoted scripts, plugins, and configuration. Before launch, you’ll review every new server ID, port, and key.

+ {#if reason}

{reason}

{/if}
{/if} @@ -94,11 +98,12 @@ diff --git a/src/lib/ui/components/ConfigInspector.svelte b/src/lib/ui/components/ConfigInspector.svelte index bba290d..ff7c85a 100644 --- a/src/lib/ui/components/ConfigInspector.svelte +++ b/src/lib/ui/components/ConfigInspector.svelte @@ -3,10 +3,14 @@ import { serverTitle } from '$ui/server-labels.js'; import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js'; import Button from './primitives/Button.svelte'; + import { saveLibraryContext } from '$ui/library-context.js'; let { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props(); let servers = $state([]); let selected = $state(''); let inspection = $state(null); + let promoted = $state(null); + let expanded = $state([]); + let inspectedServer = ''; let loading = $state(true); let error = $state(''); let query = $state(''); @@ -14,10 +18,15 @@ let raw = $state(false); let reload = $state(0); let ready = $state(false); + let desiredServer = ''; const eligible = $derived(servers.filter(s => (!fixedServer || s.name === fixedServer) && (!artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false)))); const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase()))); const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0); + const previousValues = $derived(new Map((promoted?.settings ?? []).map(s => [s.key, s.value]))); + const changes = $derived(promoted && inspection ? inspection.settings.filter(s => previousValues.get(s.key) !== s.value) : []); + const removed = $derived(promoted?.settings.filter(s => inspection && !inspection.settings.some(current => current.key === s.key)) ?? []); function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; } + function overriddenCount(source: ConfigSource) { return inspection?.settings.filter(s => (s.source.id !== source.id || s.source.kind !== source.kind) && s.history.some(h => h.source.id === source.id && h.source.kind === source.kind)).length ?? 0; } async function loadServers() { loading = true; error = ''; @@ -30,8 +39,9 @@ } catch (err) { error = (err as Error).message; } finally { loading = false; } } - onMount(loadServers); - $effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible[0]?.name ?? ''; }); + onMount(() => { desiredServer = new URLSearchParams(window.location.search).get('server') ?? ''; void loadServers(); }); + $effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible.find(s => s.name === desiredServer)?.name ?? eligible[0]?.name ?? ''; }); + $effect(() => { if (ready && selected && !fixedServer) saveLibraryContext({ server: selected }); }); $effect(() => { const server = selected; const text = draftText; @@ -39,7 +49,8 @@ const refresh = reload; if (!ready || !server) { inspection = null; if (ready) loading = false; return; } const controller = new AbortController(); - loading = true; error = ''; inspection = null; + loading = true; error = ''; + if (inspectedServer !== server) { inspection = null; promoted = null; expanded = []; inspectedServer = server; } const timer = setTimeout(async () => { try { const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, { @@ -48,7 +59,7 @@ }); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.'); - if (!controller.signal.aborted) inspection = body.inspection; + if (!controller.signal.aborted) { inspection = body.inspection; promoted = body.promoted ?? null; } } catch (err) { if (!controller.signal.aborted) error = (err as Error).message; } finally { if (!controller.signal.aborted) loading = false; } }, text === undefined ? 0 : 350); @@ -71,26 +82,26 @@ {#each eligible as server}{/each} {#if !eligible.length}{/if} - {#if error} - {:else if loading}

Composing configuration…

- {:else if !eligible.length}

{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}

+ {#if error}{/if} +

{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}

+ {#if !eligible.length && !loading}

{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}

{:else if inspection?.reason}

{inspection.reason}

{:else if inspection} -
+
    {#each inspection.sources as source, index} - {#if index}{/if} - {#if source.id}{label(source)} - {:else}{source.name}{/if} +
  1. {index + 1}.{#if source.id}{label(source)} + {:else}{source.name}{/if}{overriddenCount(source)} settings superseded later
  2. {/each} -
+

Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.

+ {#if promoted}

Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.

{#if removed.length}
Removed settings
    {#each removed as setting}
  • {setting.key} · previously {setting.value}
  • {/each}
{/if}{/if}
{#if raw} {:else}
{#each settings as setting (setting.key)} -
{setting.key}{setting.value}
{label(setting.source)} {#if setting.history.length > 1}{setting.history.length - 1} overridden{/if}
+
{ const open = event.currentTarget.open; expanded = open ? [...new Set([...expanded, setting.key])] : expanded.filter(key => key !== setting.key); }}>
{setting.key}{setting.value}
{label(setting.source)} {#if setting.history.length > 1}{setting.history.length - 1} overridden{/if}{#if promoted && previousValues.get(setting.key) !== setting.value}{previousValues.has(setting.key) ? `Changed from ${previousValues.get(setting.key)}` : 'Added by draft'}{/if}
    {#each setting.history as entry, index}
  1. {label(entry.source)}{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}
    {entry.value}
  2. {/each}
{:else}

No matching settings.

{/each} @@ -110,10 +121,14 @@ .server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); } select, input:not([type='checkbox']) { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font: var(--text-sm) var(--font-body); } select:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible, a:focus-visible, .composed:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; } - .sources { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 20px; font-size: var(--text-xs); } + .sources { display: grid; gap: 8px; margin: 20px 0 0; padding: 0; border: 0; font-size: var(--text-xs); list-style: none; } + .sources li { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 0; } + .layer-number { color: var(--text-primary); font-weight: 600; } + .preview-status { font-size: var(--text-xs); color: var(--text-secondary); min-height: 20px; } + .change-summary { display: block; font-size: var(--text-xs); color: var(--state-warning); margin-top: 8px; } .sources a, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; } .sources a.draft { color: var(--state-warning); } - .map-source, .arrow { color: var(--text-secondary); } + .map-source { color: var(--text-secondary); } .hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; } .toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; } .switch { display: flex; gap: 4px; } @@ -142,4 +157,5 @@ .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; } .error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); } @media (max-width: 600px) { .inspector { padding: 16px; } } + input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); } diff --git a/src/lib/ui/components/ConfigLibrary.svelte b/src/lib/ui/components/ConfigLibrary.svelte index a2eac6c..7faee00 100644 --- a/src/lib/ui/components/ConfigLibrary.svelte +++ b/src/lib/ui/components/ConfigLibrary.svelte @@ -3,13 +3,14 @@ import Tabs from './primitives/Tabs.svelte'; import ListFooter from './primitives/ListFooter.svelte'; import { mapLabel } from '$lib/script-library.js'; - import { goto } from '$app/navigation'; + import { afterNavigate, goto } from '$app/navigation'; import SectionHeader from './primitives/SectionHeader.svelte'; import Button from './primitives/Button.svelte'; import Badge from './primitives/Badge.svelte'; import EmptyState from './primitives/EmptyState.svelte'; import ConfigTextEditor from './ConfigTextEditor.svelte'; import ConfigInspector from './ConfigInspector.svelte'; + import { libraryLimit, saveLibraryContext } from '$ui/library-context.js'; interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; } interface Artifact { @@ -27,7 +28,11 @@ let status = $state(''); let view = $state('library'); let limit = $state(25); - $effect(() => { query; game; scope; status; limit = 25; }); + let mounted = $state(false); + afterNavigate(() => { mounted = true; }); + let lastFilters = ''; + $effect(() => { const key = JSON.stringify([query, game, scope, status]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; }); + $effect(() => { if (mounted) saveLibraryContext({ q: query, game, scope, status, view, limit }, { view: 'library', limit: 25 }); }); let dialog: HTMLDialogElement; let kind = $state<'baseline' | 'config'>('config'); let name = $state(''); @@ -67,7 +72,7 @@ } catch (err) { error = (err as Error).message; } finally { loading = false; } } - onMount(load); + onMount(() => { const params = new URLSearchParams(window.location.search); query = params.get('q') ?? ''; game = params.get('game') ?? ''; scope = params.get('scope') ?? ''; status = params.get('status') ?? ''; view = params.get('view') === 'inspect' ? 'inspect' : 'library'; limit = libraryLimit(params); void load(); }); function openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); } function resetFilters() { query = ''; game = ''; scope = ''; status = ''; } async function importFile(event: Event) { @@ -102,20 +107,20 @@ {#snippet actions()}{/snippet} -
{baselines.length} baselines{overlays.length} overlays{pending} awaiting promotion
+ {#if artifacts.length}
{baselines.length} baselines{overlays.length} overlays{pending} awaiting promotion
{/if}
{#if error}{/if} {#if view === 'inspect'} {:else} -
+ {#if loading || artifacts.length || error}
{#if query || game || scope || status}{/if} -
+
{/if} {#if loading}
Loading your config library…
{:else if !artifacts.length && !error}
Start with a foundation

Your fleet, configured your way.

Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.

@@ -218,4 +223,5 @@ .card-footer { padding-top:12px; margin-top:12px; } .welcome { padding:24px; } .loading { padding:24px 0; } + input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); } diff --git a/src/lib/ui/components/ConfigTextEditor.svelte b/src/lib/ui/components/ConfigTextEditor.svelte index 627cd1f..dafae18 100644 --- a/src/lib/ui/components/ConfigTextEditor.svelte +++ b/src/lib/ui/components/ConfigTextEditor.svelte @@ -1,8 +1,17 @@ { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> @@ -54,9 +73,12 @@ {#if error}{/if} {#if !preview}

{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}

{#if !busy}{/if} {:else} + {#if keyNotice}

{keyNotice}

{/if} + {#if expired}

This preview expired. Refresh before launch; keys remain entered for unchanged IDs.

{/if}
{preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'} {#each preview.entries as entry}{/each}
MapTargetExisting / queuedTo create
{entry.recipe.mapName} {entry.recipe.game.toUpperCase()}{entry.count}{entry.existing}{entry.missing}
-

Existing and queued servers count toward each map’s target, including stopped servers. Extra servers are kept. Servers awaiting removal do not count.

+

Targets count inventory, including stopped servers; they do not mean every server is ready to play. Only missing servers are created. Extra servers remain and existing servers are not updated.

+ {#if preview.entries.some(entry => entry.existing)}
Review existing servers{#each preview.entries.filter(entry => entry.existing) as entry}
{entry.recipe.mapName}: {entry.existing} existing

{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}

    {#each entry.coverage ?? [] as server}
  • {server.health === 'online' ? 'Healthy' : server.health}
  • {/each}
{/each}
{/if}
{#if preview.servers.length}
@@ -70,15 +92,15 @@

Add a key for each new lobby below, or reuse its saved key. Fleet counts and available slots are checked again when you queue the launch.

{ event.preventDefault(); void launch(); }}>
    - {#each preview.servers as server, index} + {#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
  1. -
    {#if server.name}{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}
    - {#if server.name}{/if} +
    {#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}{/if}{#if server.name}{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}
    + {#if server.name}{/if}
  2. {/each}
- +
{:else}

Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.

{/if}
Setup and launch requirements

Each server uses the latest promoted setup when its turn begins. The host must have the selected games and DLC. This preview checks settings and capacity; it does not probe game files, images, credentials, or network access.

{#each preview.entries as entry}
{/each}
@@ -87,10 +109,15 @@
diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 8edfd1d..3f48ca1 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -1,4 +1,5 @@
- ({id:t.key,label:t.label}))} bind:value={active} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" /> + ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
{#if current?.mtime} {fmtBytes(current.size)} · updated {ago(current.mtime)} {/if}
+
+
{#if refreshError}{/if} @@ -142,7 +176,9 @@ {:else if current && (!current.lines || current.lines.length === 0)}
log is empty
{:else if current?.lines} -
{current.lines.join('\n')}
+ +
{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}{part.text}{:else}{part.text}{/if}{/each}{'\n'}{/each}
+ {#if query && !visibleLines.length}

No matches in the loaded tail. Try another search or download the tail.

{/if} {#if current.truncated}
showing last {current.lines.length} lines (file is larger)
{:else} @@ -153,6 +189,12 @@
diff --git a/src/lib/ui/components/OperationHistory.svelte b/src/lib/ui/components/OperationHistory.svelte index efdd132..25c1b58 100644 --- a/src/lib/ui/components/OperationHistory.svelte +++ b/src/lib/ui/components/OperationHistory.svelte @@ -3,6 +3,11 @@ import Button from './primitives/Button.svelte'; let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props(); let limit = $state(25); + let query = $state(''); + let newestFirst = $state(true); + const latest = $derived(events.at(-1)); + const matching = $derived(events.filter(event => event.message.toLowerCase().includes(query.toLowerCase()))); + const visible = $derived(newestFirst ? matching.slice(-limit).reverse() : matching.slice(0, limit));
@@ -17,23 +22,37 @@
-

Activity

{#if active}Updates automatically{/if}
- {#if events.length} - {#if events.length > limit}{/if} + {#if latest}

Latest update {latest.message}

{/if} +
+ Activity history · {events.length} events{active ? ' · updates automatically' : ''} + {#if events.length} +
    - {#each events.slice(-limit) as event (event.id)} + {#each visible as event (event.id)}
  1. {event.message}

  2. {/each}
+ {#if !matching.length}

No matching activity.

{/if} + {#if matching.length > limit}{/if} {:else}

This operation predates detailed activity tracking. Its recorded status and timestamps are shown on this page.

{/if} +
diff --git a/src/lib/ui/components/ScriptUploadDrawer.svelte b/src/lib/ui/components/ScriptUploadDrawer.svelte index cd5e9a2..87f95d5 100644 --- a/src/lib/ui/components/ScriptUploadDrawer.svelte +++ b/src/lib/ui/components/ScriptUploadDrawer.svelte @@ -24,6 +24,8 @@ let message = $state(''); let preferredTarget = $state(null); let nextKey = 0; + let activeFile = $state(''); + const checkedCount = $derived(queue.filter(q => q.inspected).length); const pending = $derived(queue.filter(q => !q.result)); const inspecting = $derived(queue.some(q => q.inspecting)); const hasNew = $derived(pending.some(q => q.target === 'new')); @@ -56,19 +58,20 @@ return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase()); } export async function addFiles(files: File[]) { - if (busy || !files.length) return; + if (busy || inspecting || !files.length) return; if (!dialog.open) open(); - for (const file of files) { + const items = files.map(file => { const matches = matchingScripts(file); const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'), name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null }; - queue = [...queue, item]; - // Work with the reactive proxy, rather than the original object. - await inspect(queue.find(q => q.key === item.key)!); - } + return item; + }); + queue = [...queue, ...items]; + for (const item of items) await inspect(queue.find(q => q.key === item.key)!); preferredTarget = null; } async function inspect(item: Upload) { + activeFile = item.file.name; item.inspecting = true; item.error = ''; try { const form = new FormData(); form.append('content', item.file); @@ -77,7 +80,7 @@ if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.'); item.duplicates = body.duplicates; item.inspected = true; } catch (e) { item.error = (e as Error).message; } - finally { item.inspecting = false; } + finally { item.inspecting = false; activeFile = ''; } } function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); } function validation(item: Upload): string { @@ -99,6 +102,7 @@ busy = true; message = ''; try { for (const item of queue.filter(q => !q.result)) { + activeFile = item.file.name; item.error = ''; try { const form = new FormData(); @@ -126,7 +130,7 @@ if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); } } } catch (e) { message = (e as Error).message; } - finally { busy = false; } + finally { busy = false; activeFile = ''; } } function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); } @@ -183,6 +187,7 @@ {/if} {#if message}

{message}

{/if}
+ {#if inspecting || busy}

{inspecting ? `Checked ${checkedCount} of ${queue.length} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}

{:else if queue.length}

{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} need attention` : ''}

{/if} {#if pending.length} {:else if testable.length}{/if}
@@ -205,6 +210,8 @@ input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; } .success, .warning, .error { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .success { color: var(--state-online); } .warning { color: var(--state-warning); } .error { color: var(--state-danger); } a { color: inherit; } .notice { padding: 14px; background: var(--surface-raised); border-radius: var(--radius-md); font-size: var(--text-sm); line-height: 1.6; } - footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border-subtle); } + footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding: 16px 0; border-top: 1px solid var(--border-subtle); position: sticky; bottom: 0; z-index: 3; background: var(--surface); } + .batch-progress { flex-basis: 100%; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; } @media (max-width: 640px) { .drawer { padding: 20px; } } + input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); } diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index b14d5e5..154c61c 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -80,7 +80,7 @@ let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince); let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null); let showTiming = $derived( - showRoster && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null) + health === 'online' && (activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null) ); // Shared-element transition to /servers/{name} — Chromium only, others @@ -109,6 +109,8 @@ Starting {:else if health === 'offline'} offline + {:else if health === 'stale'} + Stale {:else}
{#if showMap}{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}{/if}{#if gtLabel}{gtLabel}{/if}
{/if} + {#if health === 'stale'} +
Players unknownLast known: {players ?? 'unknown'}{players !== null ? ' players' : ''}{lastSeenAt ? ` · ${ago(lastSeenAt, $uiNow)}` : ''}
+ {#if topRoster.length}
Last reported players
{/if} + {/if} + {#if health === 'online' && players !== null && players > 0 && gameInProgress}{/if} {#if showTiming && !(compactEmpty && players === 0)} @@ -185,6 +192,9 @@ diff --git a/src/lib/ui/components/primitives/Select.svelte b/src/lib/ui/components/primitives/Select.svelte index 2dc4f4d..4e67d6c 100644 --- a/src/lib/ui/components/primitives/Select.svelte +++ b/src/lib/ui/components/primitives/Select.svelte @@ -69,7 +69,7 @@ appearance: none; padding: 9px 32px 9px 12px; background: var(--surface-sunken); - border: 1px solid var(--border-subtle); + border: 1px solid var(--border-control); border-radius: var(--radius-md); color: var(--text-primary); font-family: var(--font-body); @@ -81,7 +81,7 @@ background-color var(--dur-fast) var(--ease-standard), box-shadow var(--dur-fast) var(--ease-standard); } - .select:hover { border-color: var(--border-strong); } + .select:hover { border-color: var(--border-control-hover); } .select:focus { outline: none; border-color: var(--accent-primary); diff --git a/src/lib/ui/components/primitives/Tabs.svelte b/src/lib/ui/components/primitives/Tabs.svelte index 167469d..04d6499 100644 --- a/src/lib/ui/components/primitives/Tabs.svelte +++ b/src/lib/ui/components/primitives/Tabs.svelte @@ -1,4 +1,5 @@ -
+
{#each tabs as t (t.id)}
- + {#if secondary.length}{/if}
@@ -127,6 +142,10 @@
+ + +
{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Server deployments'],['g a','Rollouts'],['g b','Betas'],['g h','System status'],['g k','Server keys'],['g l','Leaderboard'],['?','This help']] as [keys,label]}
{keys}
{label}
{/each}
+
diff --git a/src/routes/betas/[id]/+page.svelte b/src/routes/betas/[id]/+page.svelte index 47552d9..8b41d78 100644 --- a/src/routes/betas/[id]/+page.svelte +++ b/src/routes/betas/[id]/+page.svelte @@ -1,375 +1,187 @@ Beta #{id} | Electric Cherry -{#if loadError}{/if} - -{#if loading} -
Loading…
-{:else if !beta} - -{:else} - {@const b = beta} -
- - {#snippet crumbs()} - - {/snippet} - {#snippet actions()} - - {/snippet} - - - -
-
Started{new Date(b.opened_at).toLocaleString()}
-
Finished{b.closed_at ? new Date(b.closed_at).toLocaleString() : 'In progress'}
-
-
- - - - - -
-

Artifacts

- {scripts.length} -
- {#if scripts.length === 0} - - {:else} -
- {#each scripts as s (s.id)} -
- {s.kind} - {s.name} - {s.target_rel_path} - {artifactVersionLabel(s.version)} - {s.version?.sha256.slice(0, 8) ?? ''} -
- {/each} -
- {/if} -
- - {#if test} - - {:else} - - -

Options

-
-
- Wait for health - {b.wait_for_health ? 'Yes' : 'No'} -
-
- Wait for match - {b.wait_for_match ? 'Yes' : 'No'} -
-
-
- - {/if} - - - -
-

Server progress

- {servers.length} -
-
- {#each servers as bs (bs.server)} -
- - -
- Deploy - Empty - Restart - {#if b.wait_for_health}Healthy{/if} - {#if b.wait_for_match} - Match - {/if} -
- {#if bs.error} - {bs.error} - {/if} - {#if bs.stage === 'failed' && !test} - - {/if} -
- {/each} -
-
- - - -

Actions

-
- - -
- {#if flash}
{flash}
{/if} -
-
+{#if loadError}{/if} +{#if loading} +{:else if !beta && !loadError} +{:else if beta} +
+ + {#snippet crumbs()}{/snippet} + {#snippet actions()}{/snippet} + + +

{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}

{#if test}{coverageSummary(test.plan)}{/if}
+

{nextAction}

+ {#if test?.reason}

{test.reason}

{/if} +
Started + {#if deadline && !closed}Ends · {deadlineText}{/if} + {#if beta.closed_at}Finished {new Date(beta.closed_at).toLocaleString()}{/if} +
+ {#if !closed && test}

Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.

{/if} +
+ {#if !closed}{/if} + {#if closed}{/if} + {#if promotion}{/if} +
+ {#if promotion}

{promotion.pending} servers awaiting activation · {promotion.failed} need attention. Promoting a version does not mean it is already running everywhere.

{/if} + {#if flash}{/if} + {#if success}

{success}

{/if} +
+ {#if !closed && waiting.length} +

Outstanding conditions

+ {#each waiting as server}

{server.error ?? outstanding(server.server)}

{server.error ? 'Diagnose' : 'View checks'}
{/each} +
+ {/if} + {#if test}
server.error?.startsWith('deploy:')) ? 'Deployment was incomplete. Roll back before opening a fresh beta.' : undefined} onUpdate={load} />
{/if} +
Files and versions ({scripts.length})
{#each scripts as file}
{file.name}{artifactVersionLabel(file.version)}{file.target_rel_path}{file.version?.sha256.slice(0,12)}
{/each}
+
Deployment progress ({servers.length} servers)
+ {#each ranked as server}

Files {server.deployed_at ? 'installed' : 'pending'} · Restart {server.restarted_at ? 'completed' : 'pending'}{#if beta.wait_for_health} · Health {server.healthy_at ? 'verified' : 'pending'}{/if}{#if beta.wait_for_match} · Match {server.match_seen_at ? 'observed' : 'pending'}{/if}

{#if server.error}

{server.error}

{/if}{#if server.stage === 'failed' && !test && !closed}{/if}
{/each} +
+ +
{/if} + + {#if acting === 'preview'}

Preparing promotion impact…

{/if} + {#if reviewError}{/if} + {#if preview} +

{preview.servers.filter(server => server.tested).length} tested servers and {preview.servers.filter(server => !server.tested).length} additional eligible servers.

+

Files are installed when you confirm. Tested servers have already restarted. Additional servers restart when empty, then their health is checked. Eligibility is determined by each file's current scope.

+

Reviewed {new Date(preview.createdAt).toLocaleString()}. If targets, versions, or composed settings change, you must review again.

+ {#each preview.blockers as blocker}{/each} +
{#each preview.files as file}
{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers

{file.path} · {file.scope} · SHA {file.sha256.slice(0,12)}

    {#each file.servers as name}
  • {preview.servers.find(server => server.name === name)?.tested ? 'Already tested' : 'Additional target · restart when empty'}
  • {/each}
{/each}
+ {#if preview.configWrites.length}
Composed configuration files ({preview.configWrites.length}){#each preview.configWrites as write}

{write.server} · {write.path} · {write.sha256?.slice(0,12) ?? write.error}

{/each}
{/if} + {:else if acting !== 'preview'}{/if} + {#snippet actions()}{#if preview}{/if}{/snippet} +
+ diff --git a/src/routes/betas/new/+page.svelte b/src/routes/betas/new/+page.svelte index 2c53c85..9e5fe86 100644 --- a/src/routes/betas/new/+page.svelte +++ b/src/routes/betas/new/+page.svelte @@ -1,422 +1,225 @@ +New beta | Electric Cherry
- - {#snippet actions()}{/snippet} - - {#if flash}{/if} + {#snippet actions()}{/snippet} + {#if flash}{/if} {#if $fleetError}{/if} - {#if loading} - {:else if eligibleScripts.length === 0 && !flash} + {#if notice}

{notice}

{/if} + {#if loading} + {:else if availableDraft}

Resume unfinished beta?

Saved {new Date(availableDraft.savedAt).toLocaleString()} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.

+ {:else if !eligibleFiles.length && !flash} {:else if scripts.length} -
    - {#each steps as label, index}
  1. {index + 1}{label}
  2. {/each} -
-
-

{steps[step]}

- {#if step === 0} -
- {#each picks as row, idx (row.key)} -
-
- - ▾ -
-
- - ▾ -
- -
- {/each} -
- -
- - {:else if step === 1} -
- {#if candidateServers.length === 0} - No server satisfies all selected scopes — pick different scripts. - {:else} - {#each candidateServers as name (name)} - - {/each} - {/if} -
-
- - {:else if step === 2} - {:else} -
-

Files and versions

-
    {#each picks as pick}
  • {scripts.find(s => s.id === pick.scriptId)?.name} · {artifactVersionLabel(versionsForRow(pick).find(v => v.id === pick.versionId))}
  • {/each}
-

Test servers ({pickedServers.size})

-
    {#each [...pickedServers] as name}
  • {/each}
-

Pass criteria

{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour testing window

-
    {#each testPlan.requirements as requirement}
  • {requirementSummary(requirement)}
  • {/each}
-

Rollout and failure handling

-

{testPlan.waveSize ? `${testPlan.waveSize} servers per wave.` : 'Deploy to all selected servers.'} {testPlan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : testPlan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold the beta for review.'} {testPlan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; restart the servers manually.'}

-

Starting installs these versions and restarts the selected servers. Promotion to the rest of the fleet is a separate action after testing.

-
- {/if} -
- - {#if step < 3} - {:else}{/if} -
- +
    {#each steps as label,index}
  1. {index+1}{label}
  2. {/each}
+
{#if storageUnavailable}Draft saving is unavailable in this browser. Keep this page open to preserve your work.{:else}Unfinished changes save in this browser.{/if}{#if dirty}{/if}
+
+

{steps[step]}

+ {#if step===0} + +
{#each picks as pick,index(pick.key)}
+ + + + {#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}

{scopeText(file)} · {scopeCount(file)} eligible servers · Inspect file and versions{#if !loadingVersions.has(pick.key)&&!versionsFor(pick).length}
No unpromoted versions. Upload a new version from the file page.{/if}

{/if}{/if} +
{/each}
+ +

Test servers must match every file's scope. Changing a file retains compatible server selections.

+ {:else if step===1} +

{pickedServers.size} selected · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.

+
Selected file scopes
    {#each picks as pick}{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}
  • {file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers
  • {/if}{/if}{/each}
+ {#if !candidateServers.length}
The selected file scopes have no servers in common. Review the scopes above, then go Back to replace or remove a conflicting file.
+ {:else} +
+
{visibleServers.length} visible
+ {#each groups as group}
{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}
{#each group.servers as server}{/each}
{/each} + {#if !visibleServers.length}

No servers match these filters.

{/if} + {/if} + {:else if step===2} + {:else}

Files and exact versions

    {#each picks as pick}
  • {scripts.find(file=>file.id===pick.scriptId)?.name} · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}
  • {/each}

Test servers ({pickedServers.size})

    {#each [...pickedServers] as name}
  • {/each}

Pass criteria

{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.

    {#each testPlan.requirements as requirement}
  • {requirementSummary(requirement)}
  • {/each}

Rollout and failure handling

{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}

Starting installs the selected versions and restarts test servers when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.

{/if} +

{forwardReason}

{#if step<3}{:else}{/if}
+ {/if}
+ diff --git a/src/routes/campaigns/+page.svelte b/src/routes/campaigns/+page.svelte index 2ddb466..5269e04 100644 --- a/src/routes/campaigns/+page.svelte +++ b/src/routes/campaigns/+page.svelte @@ -1,5 +1,9 @@ +Rollouts | Electric Cherry +
reviewDrift(false)} + disabled={running || scanning || reviewing || !selected.length} loading={running} > - Reconcile + Review {selected.length} selected +
{#if driftOpen}
+ {#if drift}

Scanned {new Date(drift.scannedAt).toLocaleString()}. Selection controls the action scope; search only changes the visible list.

{/if} {#if drift && drift.totalDrift > 0} { driftFilter = event.currentTarget.value; saveContext(); }} /> +
{selected.length} selected
{#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
- + {s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}
@@ -296,9 +322,13 @@
{/if} - {#if flash} -
{flash}
- {/if} + {#if result}
0} role={skipped.length ? 'alert' : 'status'}> + {result.totalServers} server{result.totalServers === 1 ? '' : 's'} updated{skipped.length ? `, ${skipped.length} with skipped files` : ''} +

{result.totalRemoved} files removed; {result.totalRestored} installed. {result.totalServers ? 'Affected servers restart when empty, then health is verified.' : skipped.length ? 'No changes could be applied. Review the reasons below.' : 'The reviewed servers already match the intended files.'}

+ {#if result.deploymentId}View rollout →{/if} + {#each skipped as server}
    {#each server.skipped as reason}
  • {reason}
  • {/each}
{/each} + {#if skipped.length}{/if} +
{/if} @@ -345,12 +375,25 @@ {/each} {/if} - + {/if}
+ + {#if planError}{/if} + {#if reviewing}

Comparing current files with their intended versions…

+ {:else if plan} +

{reviewAll ? 'Entire fleet' : 'Selected servers'}: {plan.servers.length} server{plan.servers.length === 1 ? '' : 's'}, {plan.files.length} file change{plan.files.length === 1 ? '' : 's'}.

+

Files change immediately. Each affected server restarts after a fresh check confirms it is empty, then waits for health verification. Untracked files will be removed.

+

Reviewed {new Date(plan.scannedAt).toLocaleString()}. Changes to this plan require another review.

+
{#each plan.servers as server}{@const files = plan.files.filter(file => file.server === server)}{#if files.length}

{#each files as file}
{file.driftKind === 'untracked' ? 'Remove' : file.driftKind === 'missing' ? 'Restore' : 'Replace'} {file.relPath}{file.beforeSize === null ? 'Absent' : `${file.beforeSize} bytes`} → {file.afterSize === null ? 'Removed' : `${file.afterSize} bytes`}
File fingerprints

Before: {file.beforeSha256 ?? 'Absent'}
After: {file.afterSha256 ?? 'Removed'}

{/each}
{/if}{/each}
+ {/if} + {#snippet actions()}{/snippet} +
+ {#snippet deployRow(d: Deployment)}
+ {#if flash && flashId === d.id}
{flash}
{/if}
{d.action} rollout {d.id} @@ -384,7 +427,7 @@ title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}" > - {serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''} + {serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''} {/each}
@@ -403,6 +446,16 @@ {/snippet} diff --git a/src/routes/server-keys/+page.svelte b/src/routes/server-keys/+page.svelte index 8a2183f..baa7edd 100644 --- a/src/routes/server-keys/+page.svelte +++ b/src/routes/server-keys/+page.svelte @@ -1,5 +1,8 @@ +{serverTitle(name)} · {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} · Electric Cherry {#if server} @@ -223,9 +231,7 @@ {/snippet} {#snippet actions()} - +
Server actions
{/snippet} @@ -280,9 +286,15 @@ {#if $fleetError}{/if} + {#if hasDiagnosticIssue} +
+
{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}

Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {integrations && !integrations.rcon ? 'RCON is not configured.' : ''}

+
{#if integrations && (!integrations.rcon || !integrations.komodo)}{/if}
+
+ {/if} {#if operations.length}

Current operations

{#each operations as operation (operation.key)}{operation.label}{operation.detail}{/each}
{/if}
- +
@@ -329,9 +341,6 @@ {:else if activeTab === 'diagnostics'} - {#key name} - - {/key}

RCON diagnostic

@@ -493,6 +502,7 @@ {/if}
+
{#key name}{/key}
{:else if activeTab === 'history'}
@@ -537,6 +547,12 @@ {/if} diff --git a/tests/unit/beta-promotion.test.ts b/tests/unit/beta-promotion.test.ts new file mode 100644 index 0000000..8764063 --- /dev/null +++ b/tests/unit/beta-promotion.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +const servers = ['t5_zombie_theater_1', 't5_zombie_cosmodrome_1']; +beforeEach(async () => { + vi.resetModules(); fixture = await newFixture(servers); + vi.stubEnv('KOMODO_ADDRESS', ''); vi.stubEnv('RCON_PASSWORD', ''); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); +}); +afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); }); + +async function ready(kind: 'script' | 'baseline' = 'script', scope: 'game' | 'map' = 'game') { + const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js'); + const { openBeta } = await import('../../src/lib/server/fleet/beta.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const { script } = createScript({ name: 'Review test', kind, targetRelPath: kind === 'script' ? 'review.gsc' : undefined, + scopeType: scope, scopeValue: scope === 'game' ? 't5' : 'zombie_theater', content: Buffer.from(kind === 'script' ? 'main(){}' : 'set sv_voice "1"\n') }); + const version = getLatestVersion(script.id)!; + const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [servers[0]], waitForHealth: false, waitForMatch: false }); + db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(betaId); + db().prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?").run(new Date().toISOString(), betaId); + return { betaId, script, version }; +} +const destination = () => join(fixture.t5, servers[1], 'runtime/plutonium/storage/t5/raw/scripts/sp/review.gsc'); + +describe('reviewed beta promotion', () => { + it('previews exact versions and additional targets without writing, then links the activation rollout', async () => { + const { betaId, script, version } = await ready(); + const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); + const preview = previewBetaPromotion(betaId); + expect(preview.files[0]).toMatchObject({ id: script.id, versionId: version.id, sha256: version.sha256, servers: [...servers].sort() }); + expect(preview.servers).toEqual(expect.arrayContaining([expect.objectContaining({ name: servers[0], tested: true }), expect.objectContaining({ name: servers[1], tested: false })])); + expect(previewBetaPromotion(betaId).token).toBe(preview.token); + expect(existsSync(destination())).toBe(false); + const result = await promoteBeta(betaId, preview.token); + expect(result).toMatchObject({ awaitingActivation: 1, failedServers: [] }); + expect(result.deploymentId).toContain('promote'); + expect(await readFile(destination(), 'utf8')).toBe('main(){}'); + }); + it('rejects changed scope membership before creating a rollout or writing extra files', async () => { + const { betaId } = await ready('script', 'map'); + const { previewBetaPromotion, promoteBeta, getBeta } = await import('../../src/lib/server/fleet/beta.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const preview = previewBetaPromotion(betaId); + expect(preview.servers).toHaveLength(1); + db().prepare('UPDATE servers SET map = ? WHERE name = ?').run('zombie_theater', servers[1]); + await expect(promoteBeta(betaId, preview.token)).rejects.toMatchObject({ kind: 'preview-changed', details: expect.objectContaining({ servers: expect.arrayContaining([expect.objectContaining({ name: servers[1] })]) }) }); + expect(existsSync(destination())).toBe(false); + expect(getBeta(betaId)?.state).toBe('ready-promote'); + expect(db().prepare('SELECT COUNT(*) n FROM deployments').get()).toEqual({ n: 0 }); + await expect(promoteBeta(betaId, previewBetaPromotion(betaId).token)).resolves.toMatchObject({ awaitingActivation: 1 }); + }); + it('includes effective config contents in the guard, including map-specific composition', async () => { + const { betaId } = await ready('baseline'); + const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + const preview = previewBetaPromotion(betaId); + expect(preview.configWrites).toHaveLength(2); + expect(new Set(preview.configWrites.map(write => write.sha256)).size).toBe(2); + db().prepare('UPDATE servers SET map = ?, map_slug = ? WHERE name = ?').run('zombie_moon', 'moon', servers[1]); + await expect(promoteBeta(betaId, preview.token)).rejects.toMatchObject({ kind: 'preview-changed' }); + expect(db().prepare('SELECT COUNT(*) n FROM deployments').get()).toEqual({ n: 0 }); + }); + it('requires the reviewed token at the HTTP boundary', async () => { + const { betaId } = await ready(); + const { POST, GET } = await import('../../src/routes/api/betas/[id]/promote/+server.js'); + const response = await POST({ params: { id: String(betaId) }, request: new Request('http://localhost/promote', { method: 'POST', body: '{}' }) }); + expect(response.status).toBe(400); + const preview = await GET({ params: { id: String(betaId) } }); + expect(preview.headers.get('cache-control')).toBe('no-store'); + expect((await preview.json()).preview.token).toHaveLength(64); + }); +}); diff --git a/tests/unit/beta-setup.test.ts b/tests/unit/beta-setup.test.ts new file mode 100644 index 0000000..7ac5ca5 --- /dev/null +++ b/tests/unit/beta-setup.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { defaultPlan, betaPlanIssues, requirement } from '../../src/lib/beta-plan.js'; +import { readBetaDraft, compatibleBetaServers } from '../../src/lib/beta-draft.js'; +import { betaWaitingCondition, betaEvidenceSummary } from '../../src/lib/beta-evidence.js'; + +describe('beta draft and plan recovery', () => { + it('retains exact version IDs and incomplete plan fields in a recoverable draft', () => { + const plan = defaultPlan(); + const raw = JSON.stringify({ version: 1, savedAt: new Date().toISOString(), step: 2, picks: [{ scriptId: 12, versionId: 45 }], servers: ['town'], testPlan: { ...plan, timeoutHours: null } }); + const draft = readBetaDraft(raw)!; + expect(draft.picks).toEqual([{ scriptId: 12, versionId: 45 }]); + expect(betaPlanIssues(draft.testPlan, 1)).toContainEqual(expect.objectContaining({ field: 'timeoutHours' })); + expect(readBetaDraft('{bad')).toBeNull(); + expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: '12', versionId: 45 }] }))).toBeNull(); + }); + it('keeps compatible explicit selections without selecting newly eligible servers', () => { + const result = compatibleBetaServers(['town1', 'town2'], ['town2', 'town3']); + expect([...result.kept]).toEqual(['town2']); + expect(result.removed).toEqual(['town1']); + }); + it('identifies the precise requirement and rejects an impossible quorum before review', () => { + const plan = { ...defaultPlan(), scope: 'quorum' as const, quorum: 3, requirements: [{ ...requirement('matches', 'matches'), target: 1.5, minPlayers: 0 }] }; + expect(betaPlanIssues(plan, 2).map(issue => issue.field)).toEqual(['quorum', 'matches-target', 'matches-minPlayers']); + expect(betaPlanIssues(plan, 2).find(issue => issue.field === 'matches-target')?.requirementId).toBe('matches'); + }); +}); +describe('beta evidence explanations', () => { + const observation = { lastGameplayAt: new Date().toISOString(), lastEvent: 'round_change', players: 2, round: 12, matchStarted: true, gameEnded: false, healthy: true, healthCheckedAt: new Date().toISOString() }; + it('distinguishes missing reports, insufficient population, and report-driven time', () => { + const r = { ...requirement('populated-minutes', 'play'), minPlayers: 3 }; + expect(betaWaitingCondition(r)).toContain('No gameplay report'); + expect(betaWaitingCondition(r, observation)).toContain('latest report has 2 players'); + expect(betaWaitingCondition(r, { ...observation, players: 3 })).toContain('next report arrives'); + }); + it('summarizes telemetry while keeping arbitrary or legacy payloads readable', () => { + const event = { id: 1, server: 'town', received_at: '', kind: 'game_end', payload: JSON.stringify({ round: 25, players: [{ name: 'A' }], map: 'zm_transit' }) }; + expect(betaEvidenceSummary(event)).toBe('Game ended at round 25 with 1 players on zm_transit.'); + expect(betaEvidenceSummary({ ...event, kind: 'old-event', payload: 'invalid' })).toBe('old event'); + }); +}); diff --git a/tests/unit/config-inspect.test.ts b/tests/unit/config-inspect.test.ts index 4cf9b5d..5c92ac5 100644 --- a/tests/unit/config-inspect.test.ts +++ b/tests/unit/config-inspect.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { inspectConfig, type ConfigLayer } from '../../src/lib/server/fleet/config-inspect.js'; import { composeServerCfg } from '../../src/lib/server/fleet/config-compose.js'; -import { changeConfigValue, configWarnings } from '../../src/lib/config-workbench.js'; +import { addConfigSetting, changeConfigValue, configWarnings } from '../../src/lib/config-workbench.js'; const base = (text: string): ConfigLayer => ({ text, source: { id: 1, name: 'Base', version: 2, kind: 'baseline' } }); const overlay = (id: number, text: string): ConfigLayer => ({ text, source: { id, name: `Overlay ${id}`, version: 3, kind: 'config' } }); @@ -63,6 +63,17 @@ describe('config source inspection', () => { }); describe('config settings editor', () => { + it('adds a structured value without altering existing content or line endings', () => { + expect(addConfigSetting('// comment\r\nset x 1', 'sv_hostname', 'Cherry lobby')).toBe('// comment\r\nset x 1\r\nset sv_hostname "Cherry lobby"\r\n'); + expect(addConfigSetting('', 'g_inactivity', '0')).toBe('set g_inactivity 0\n'); + }); + + it('rejects duplicate keys, missing values, and multiline assignments', () => { + expect(() => addConfigSetting('set FOO 1', 'foo', '2')).toThrow('already exists'); + expect(() => addConfigSetting('', 'bad key', '1')).toThrow('setting name'); + expect(() => addConfigSetting('', 'key', '// no value')).toThrow('value'); + expect(() => addConfigSetting('', 'key', '1\nset other 2')).toThrow('one line'); + }); it('changes only the selected assignment, preserving CRLF, spacing and raw commands', () => { const text = '// note\r\n set a "1" // original\r\nexec other.cfg\r\nset a 5\n'; expect(changeConfigValue(text, 1, '"2" // changed')).toBe('// note\r\n set a "2" // changed\r\nexec other.cfg\r\nset a 5\n'); diff --git a/tests/unit/config-workbench-api.test.ts b/tests/unit/config-workbench-api.test.ts index 23bd940..2e720ba 100644 --- a/tests/unit/config-workbench-api.test.ts +++ b/tests/unit/config-workbench-api.test.ts @@ -29,6 +29,7 @@ describe('config workbench API', () => { expect(response.status).toBe(200); const result = await response.json(); expect(result.inspection.settings.find((s: { key: string }) => s.key === 'g_inactivity')).toMatchObject({ value: '60', source: { id: overlay.script.id, draft: true } }); + expect(result.promoted.settings.find((s: { key: string }) => s.key === 'g_inactivity').value).toBe('190'); expect(listVersions(overlay.script.id)).toHaveLength(1); expect(getScript(baseline.script.id)?.current_version_id).toBe(baseline.version.id); const { getPromotedServerConfig } = await import('../../src/lib/server/fleet/server-config.js'); diff --git a/tests/unit/create-server.test.ts b/tests/unit/create-server.test.ts index 10e73df..5b3255e 100644 --- a/tests/unit/create-server.test.ts +++ b/tests/unit/create-server.test.ts @@ -48,6 +48,60 @@ afterEach(async () => { vi.unstubAllEnvs(); }); +describe('queued removal cancellation', () => { + it('lets cancellation win while the worker awaits a player count, and never removes the server', async () => { + const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js'); + const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js'); + const { POST } = await import('../../src/routes/api/deployments/[id]/cancel/+server.js'); + const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js'); + const { komodoDestroyDeployment } = await import('../../src/lib/server/integrations/komodo.js'); + await createServer(input); + let resolveCount!: (players: number) => void; + vi.mocked(rconGetPlayerCount).mockImplementationOnce(() => new Promise(resolve => { resolveCount = resolve; })); + const queued = destroyServer(serverName); + const work = processDestructions(); + await vi.waitFor(() => expect(resolveCount).toBeTypeOf('function')); + expect((await POST({ params: { id: queued.deploymentId! } })).status).toBe(200); + resolveCount(0); + await work; + expect(komodoDestroyDeployment).not.toHaveBeenCalled(); + expect(getDeployment(queued.deploymentId!)?.deployment).toMatchObject({ state: 'cancelled', server_exists: 1, stage: 'Removal cancelled' }); + expect(getDeployment(queued.deploymentId!)?.canCancelRemoval).toBe(false); + expect((await POST({ params: { id: queued.deploymentId! } })).status).toBe(409); + }); + + it('does not overwrite cancellation when the in-flight player check rejects', async () => { + const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js'); + const { cancelRemoval, getDeployment } = await import('../../src/lib/server/fleet/deployments.js'); + const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js'); + await createServer(input); + let rejectCount!: (error: Error) => void; + vi.mocked(rconGetPlayerCount).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectCount = reject; })); + const queued = destroyServer(serverName); + const work = processDestructions(); + await vi.waitFor(() => expect(rejectCount).toBeTypeOf('function')); + expect(cancelRemoval(queued.deploymentId!)).toBe(true); + rejectCount(new Error('RCON unavailable')); + await work; + expect(getDeployment(queued.deploymentId!)?.deployment).toMatchObject({ state: 'cancelled', error: null }); + }); + + it('refuses cancellation after an atomic removal claim and for cleanup checkpoints', async () => { + const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js'); + const { cancelRemoval, claimRemoval, canCancelRemoval, deploymentStage, REMOVING_SERVER } = await import('../../src/lib/server/fleet/deployments.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + await createServer(input); + const queued = destroyServer(serverName); + expect(claimRemoval(queued.deploymentId!)).toBe(true); + expect(cancelRemoval(queued.deploymentId!)).toBe(false); + expect(claimRemoval(queued.deploymentId!)).toBe(false); + deploymentStage(queued.deploymentId!, REMOVING_SERVER); + db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(queued.deploymentId); + expect(canCancelRemoval(queued.deploymentId!)).toBe(false); + expect(cancelRemoval(queued.deploymentId!)).toBe(false); + }); +}); + describe('destruction cleanup recovery', () => { it('removes T6 servers with beta test runs and their dependent evidence', async () => { const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js'); diff --git a/tests/unit/drift-kinds.test.ts b/tests/unit/drift-kinds.test.ts index 8818ea7..0963332 100644 --- a/tests/unit/drift-kinds.test.ts +++ b/tests/unit/drift-kinds.test.ts @@ -21,6 +21,57 @@ afterEach(async () => { await fixture.cleanup(); }); +describe('reviewed drift plans', () => { + it('rejects changed file bytes before any deletion and returns a fresh review', async () => { + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + const { previewDrift, runDriftDeployment, DriftPlanChanged } = await import('../../src/lib/server/fleet/drift.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + await seedServerRegistry(); + const path = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'stray.cfg'); + await writeFile(path, 'original'); + const plan = await previewDrift(['t5_zombie_theater_1']); + await writeFile(path, 'changed'); + await expect(runDriftDeployment({ servers: plan.servers, expectedFingerprint: plan.fingerprint })).rejects.toBeInstanceOf(DriftPlanChanged); + expect(await readFile(path, 'utf8')).toBe('changed'); + expect(db().prepare('SELECT * FROM deployments').all()).toEqual([]); + const refreshed = await previewDrift(plan.servers); + expect(refreshed.fingerprint).not.toBe(plan.fingerprint); + expect((await runDriftDeployment({ servers: refreshed.servers, expectedFingerprint: refreshed.fingerprint })).totalRemoved).toBe(1); + }); + + it('rejects changed replacement bytes and preserves the reviewed existing file', async () => { + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { previewDrift, runDriftDeployment, DriftPlanChanged } = await import('../../src/lib/server/fleet/drift.js'); + const { db } = await import('../../src/lib/server/db/index.js'); + await seedServerRegistry(); + const { script, version } = createScript({ name: 'Managed config', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set sv_voice "1"\n') }); + db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id); + const path = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'dedicated_sp.cfg'); + await writeFile(path, 'existing settings'); + const plan = await previewDrift(['t5_zombie_theater_1']); + db().prepare('UPDATE script_versions SET content = ? WHERE id = ?').run(Buffer.from('set sv_voice "0"\n'), version.id); + await expect(runDriftDeployment({ servers: plan.servers, expectedFingerprint: plan.fingerprint })).rejects.toBeInstanceOf(DriftPlanChanged); + expect(await readFile(path, 'utf8')).toBe('existing settings'); + }); + + it('keeps selected reconciliation within its reviewed server identities', async () => { + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + const { previewDrift, runDriftDeployment, DriftPlanChanged } = await import('../../src/lib/server/fleet/drift.js'); + const second = join(fixture.t5, 't5_zombie_theater_2', 'configs'); + await mkdir(second, { recursive: true }); + await seedServerRegistry(); + const firstPath = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'stray.cfg'); + await writeFile(firstPath, 'one'); + await writeFile(join(second, 'stray.cfg'), 'two'); + const plan = await previewDrift(['t5_zombie_theater_1']); + await expect(runDriftDeployment({ expectedFingerprint: plan.fingerprint })).rejects.toBeInstanceOf(DriftPlanChanged); + await runDriftDeployment({ servers: plan.servers, expectedFingerprint: plan.fingerprint }); + expect(existsSync(firstPath)).toBe(false); + expect(await readFile(join(second, 'stray.cfg'), 'utf8')).toBe('two'); + }); +}); + describe('drift scanning', () => { it('replaces a seeded config once instead of planning both removal and installation', async () => { const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); diff --git a/tests/unit/library-scope-preview.test.ts b/tests/unit/library-scope-preview.test.ts new file mode 100644 index 0000000..f53b4c2 --- /dev/null +++ b/tests/unit/library-scope-preview.test.ts @@ -0,0 +1,35 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +const server = 't5_zombie_theater_1'; +beforeEach(async () => { + vi.resetModules(); fixture = await newFixture([server]); + const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); + await seedServerRegistry(); +}); +afterEach(async () => { await fixture.cleanup(); }); + +describe('scope impact preview', () => { + it('reports unscoping tracked script copies without changing scope or inventory', async () => { + const { createScript, getScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { recordScript, listServerScripts } = await import('../../src/lib/server/fleet/inventory.js'); + const { GET } = await import('../../src/routes/api/scripts/[id]/scope/+server.js'); + const script = createScript({ name: 'Example', scopeType: 'game', scopeValue: 't5', targetRelPath: 'example.gsc', content: Buffer.from('example') }); + recordScript(server, 'example.gsc', script.version.id, script.version.sha256, script.version.size, 'test'); + const response = await GET({ params: { id: String(script.script.id) }, url: new URL('http://localhost/?type=none') }); + expect(await response.json()).toMatchObject({ before: [server], after: [], removed: [server], detached: [server], affected: [server], composed: false }); + expect(getScript(script.script.id)?.scope_type).toBe('game'); + expect(listServerScripts(server)).toHaveLength(1); + }); + + it('reports config composition impact separately from detached inventory', async () => { + const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); + const { GET } = await import('../../src/routes/api/scripts/[id]/scope/+server.js'); + const config = createScript({ name: 'Overlay', kind: 'config', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set g_inactivity 0') }); + const response = await GET({ params: { id: String(config.script.id) }, url: new URL('http://localhost/?type=none') }); + expect(await response.json()).toMatchObject({ composed: true, detached: [], affected: [server], removed: [server] }); + const invalid = await GET({ params: { id: String(config.script.id) }, url: new URL('http://localhost/?type=invalid&value=t5') }); + expect(invalid.status).toBe(400); + }); +}); diff --git a/tests/unit/system-status.test.ts b/tests/unit/system-status.test.ts new file mode 100644 index 0000000..8f05a74 --- /dev/null +++ b/tests/unit/system-status.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { newFixture, type Fixture } from '../helpers/fixture.js'; + +let fixture: Fixture; +beforeEach(async () => { + vi.resetModules(); + fixture = await newFixture([]); + vi.stubEnv('KOMODO_ADDRESS', ''); + vi.stubEnv('KOMODO_API_KEY', ''); + vi.stubEnv('KOMODO_API_SECRET', ''); + vi.stubEnv('KOMODO_TARGET_SERVER', ''); + vi.stubEnv('RCON_PASSWORD', ''); +}); +afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.restoreAllMocks(); }); + +it('distinguishes missing setup from an unverifiable host volume without probing', async () => { + const probe = vi.spyOn(await import('../../src/lib/server/integrations/netProbe.js'), 'tcpProbe'); + const { GET } = await import('../../src/routes/api/system-status/+server.js'); + const response = await GET({ url: new URL('http://localhost/api/system-status') }); + const body = await response.json(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(body.checks.find((check: { id: string }) => check.id === 'komodo').state).toBe('unconfigured'); + expect(body.checks.find((check: { id: string }) => check.id === 'game-files').state).toBe('unverified'); + expect(body.readyServers).toBe(0); + expect(probe).not.toHaveBeenCalled(); +}); + +it('never exposes credentials or claims that network reachability validates them', async () => { + vi.stubEnv('KOMODO_ADDRESS', 'https://komodo.example.test'); + vi.stubEnv('KOMODO_API_KEY', 'fixture-private-key'); + vi.stubEnv('KOMODO_API_SECRET', 'fixture-private-secret'); + vi.stubEnv('RCON_PASSWORD', 'fixture-rcon-secret'); + vi.spyOn(await import('../../src/lib/server/integrations/netProbe.js'), 'tcpProbe').mockResolvedValue({ label: 'Komodo', host: 'komodo.example.test', port: 443, resolvedIp: '192.0.2.1', outcome: 'connected', elapsedMs: 1 }); + const { GET } = await import('../../src/routes/api/system-status/+server.js'); + const text = await (await GET({ url: new URL('http://localhost/api/system-status?probe=1') })).text(); + for (const secret of ['fixture-private-key', 'fixture-private-secret', 'fixture-rcon-secret']) expect(text).not.toContain(secret); + const connection = JSON.parse(text).checks.find((check: { id: string }) => check.id === 'komodo'); + expect(connection.state).toBe('ready'); + expect(connection.detail).toContain('does not validate API credentials'); +}); + +it('reports a configured unreachable host as needing attention', async () => { + vi.stubEnv('KOMODO_ADDRESS', 'http://komodo.example.test:9120'); + vi.stubEnv('KOMODO_API_KEY', 'key'); vi.stubEnv('KOMODO_API_SECRET', 'secret'); + vi.spyOn(await import('../../src/lib/server/integrations/netProbe.js'), 'tcpProbe').mockResolvedValue({ label: 'Komodo', host: 'komodo.example.test', port: 9120, resolvedIp: null, outcome: 'dns-fail', elapsedMs: 1 }); + const { GET } = await import('../../src/routes/api/system-status/+server.js'); + const body = await (await GET({ url: new URL('http://localhost/api/system-status?probe=1') })).json(); + expect(body.checks.find((check: { id: string }) => check.id === 'komodo').state).toBe('attention'); +});