Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
The interface keeps the existing dark palette and blue accent, with shared controls and task-focused pages.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -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<string>, candidates: readonly string[]): { kept: Set<string>; 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)) };
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
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('_', ' ');
|
||||||
|
}
|
||||||
+25
-1
@@ -82,6 +82,30 @@ export interface BetaTestView {
|
|||||||
plan: BetaPlan;
|
plan: BetaPlan;
|
||||||
outcome: string;
|
outcome: string;
|
||||||
reason: string | null;
|
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 }>;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -45,6 +45,17 @@ export function changeConfigValue(text: string, index: number, value: string): s
|
|||||||
return parts.join('');
|
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[] {
|
export function configWarnings(text: string, overlay: boolean): string[] {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const seen = new Map<string, number>();
|
const seen = new Map<string, number>();
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export interface CollectionPreview {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
blueprintName: string;
|
blueprintName: string;
|
||||||
launchConcurrency: number;
|
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 }[];
|
servers: { entryIndex: number; name: string | null; port: number | null; hasSavedKey: boolean }[];
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
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,
|
return { plan, outcome: stored.outcome, reason: stored.reason,
|
||||||
servers: runs.map(run => {
|
servers: runs.map(run => {
|
||||||
const progress = evaluateRequirements(plan, readMetrics(run), run.phase);
|
const metrics = readMetrics(run);
|
||||||
return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress };
|
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
|
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'] };
|
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'] };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
|
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
|
||||||
import { storedPlan } from './beta-tests.js';
|
import { storedPlan } from './beta-tests.js';
|
||||||
|
import type { BetaPromotionPreview } from '../../beta-promotion.js';
|
||||||
|
|
||||||
const operations = new Set<number>();
|
const operations = new Set<number>();
|
||||||
export const betaOperationInFlight = (id: number) => operations.has(id);
|
export const betaOperationInFlight = (id: number) => operations.has(id);
|
||||||
@@ -192,6 +193,7 @@ export class BetaError extends Error {
|
|||||||
| 'dest-fail'
|
| 'dest-fail'
|
||||||
| 'invalid-state'
|
| 'invalid-state'
|
||||||
| 'duplicate-script'
|
| 'duplicate-script'
|
||||||
|
| 'preview-changed'
|
||||||
| 'duplicate-target',
|
| 'duplicate-target',
|
||||||
public readonly details?: unknown
|
public readonly details?: unknown
|
||||||
) {
|
) {
|
||||||
@@ -721,6 +723,54 @@ export interface PromoteResult {
|
|||||||
deploymentId: string;
|
deploymentId: string;
|
||||||
landed: string[]; // unique servers that received at least one file
|
landed: string[]; // unique servers that received at least one file
|
||||||
scriptIds: number[];
|
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<string, Buffer> } {
|
||||||
|
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<string, Buffer>();
|
||||||
|
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 {
|
function promoteDeploymentId(betaId: number): string {
|
||||||
@@ -728,14 +778,14 @@ function promoteDeploymentId(betaId: number): string {
|
|||||||
return `dep-${stamp}-promote-${betaId}`;
|
return `dep-${stamp}-promote-${betaId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
export async function promoteBeta(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
||||||
if (storedPlan(betaId)) {
|
if (storedPlan(betaId)) {
|
||||||
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
|
const { plannedBetaTick } = await import('../pipeline/beta-plan-runner.js');
|
||||||
await plannedBetaTick();
|
await plannedBetaTick();
|
||||||
}
|
}
|
||||||
return exclusive(betaId, () => promoteBetaInternal(betaId));
|
return exclusive(betaId, () => promoteBetaInternal(betaId, reviewedToken));
|
||||||
}
|
}
|
||||||
async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
async function promoteBetaInternal(betaId: number, reviewedToken?: string): Promise<PromoteResult> {
|
||||||
const test = storedPlan(betaId);
|
const test = storedPlan(betaId);
|
||||||
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
|
if (test && test.outcome !== 'passed') throw new BetaError('Test plan has not passed', 'not-ready-promote');
|
||||||
const beta = getBeta(betaId);
|
const beta = getBeta(betaId);
|
||||||
@@ -743,6 +793,15 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
|||||||
if (beta.state !== 'ready-promote') {
|
if (beta.state !== 'ready-promote') {
|
||||||
throw new BetaError(`beta must be ready-promote (is ${beta.state})`, 'not-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);
|
const bundleRows = listBetaScripts(betaId);
|
||||||
if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts');
|
if (bundleRows.length === 0) throw new BetaError('beta has no scripts', 'no-scripts');
|
||||||
const bundle = bundleRows.map((br) => ({
|
const bundle = bundleRows.map((br) => ({
|
||||||
@@ -755,8 +814,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
|||||||
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
|
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
|
||||||
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
|
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
|
||||||
const collapsing = 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.
|
// Build a per-(script, server) plan for non-collapsing artifacts.
|
||||||
interface Plan {
|
interface Plan {
|
||||||
@@ -776,10 +833,7 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const { script, version } of nonCollapsing) {
|
for (const { script, version } of nonCollapsing) {
|
||||||
const scopeServers = script.scope_type && script.scope_value
|
const targets = reviewed.preview.files.find(file => file.id === script.id)!.servers;
|
||||||
? serversMatchingScope(script.scope_type, script.scope_value)
|
|
||||||
: [];
|
|
||||||
const targets = new Set<string>([...betaServerSet, ...scopeServers]);
|
|
||||||
|
|
||||||
for (const name of targets) {
|
for (const name of targets) {
|
||||||
const dest = destOfKind(name, script.kind);
|
const dest = destOfKind(name, script.kind);
|
||||||
@@ -830,23 +884,6 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
|||||||
// Write files. Track per-plan success and per-server first-error.
|
// Write files. Track per-plan success and per-server first-error.
|
||||||
const planWritten = new Map<Plan, boolean>();
|
const planWritten = new Map<Plan, boolean>();
|
||||||
const serverErr = new Map<string, string>();
|
const serverErr = new Map<string, string>();
|
||||||
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
|
// Collapsing config layer: one composed cfg per server. Union of beta
|
||||||
// servers + scope-matching servers for every collapsing artifact.
|
// servers + scope-matching servers for every collapsing artifact.
|
||||||
@@ -860,29 +897,18 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
|||||||
}
|
}
|
||||||
const cfgPlans: CfgPlan[] = [];
|
const cfgPlans: CfgPlan[] = [];
|
||||||
if (collapsing.length > 0) {
|
if (collapsing.length > 0) {
|
||||||
const cfgTargets = new Set<string>(betaServerSet);
|
const cfgTargets = reviewed.preview.configWrites.map(write => write.server);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const name of cfgTargets) {
|
for (const name of cfgTargets) {
|
||||||
const srv = getServer(name);
|
const srv = getServer(name);
|
||||||
if (!srv) {
|
if (!srv) {
|
||||||
serverErr.set(name, 'unknown server during config promote');
|
serverErr.set(name, 'unknown server during config promote');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const composedResult = composeCfgForServer(name, srv.game, srv.map, {
|
const composedBuf = reviewed.configContent.get(name);
|
||||||
inBetaBaseline: collapsingBaseline?.version,
|
if (!composedBuf) {
|
||||||
inBetaOverlays: collapsingOverlays
|
|
||||||
});
|
|
||||||
if (!composedResult) {
|
|
||||||
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
|
serverErr.set(name, `no baseline available for ${name} (game=${srv.game ?? '?'})`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
|
||||||
const composedSha = sha256Buffer(composedBuf);
|
const composedSha = sha256Buffer(composedBuf);
|
||||||
const dest = destOfKind(name, 'config');
|
const dest = destOfKind(name, 'config');
|
||||||
const targetRel = configFileOf(name);
|
const targetRel = configFileOf(name);
|
||||||
@@ -913,6 +939,20 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Write composed cfg to each target.
|
||||||
const cfgWritten = new Map<CfgPlan, boolean>();
|
const cfgWritten = new Map<CfgPlan, boolean>();
|
||||||
for (const cp of cfgPlans) {
|
for (const cp of cfgPlans) {
|
||||||
@@ -1061,7 +1101,9 @@ async function promoteBetaInternal(betaId: number): Promise<PromoteResult> {
|
|||||||
betaId,
|
betaId,
|
||||||
deploymentId: depId,
|
deploymentId: depId,
|
||||||
landed,
|
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()]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { config, komodoConfigured, rconConfigured } from '../config.js';
|
|||||||
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
|
import { blueprintEntries, summarizeRecipe, type BlueprintEntry } from './launch-recipes.js';
|
||||||
import { creationQueue, nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
|
import { creationQueue, nextAvailablePort, nextLobbyNumber, queueCreateServer } from './create.js';
|
||||||
import { getServerKey } from './server-keys.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';
|
import { parseServerName, zombieMapBySlug, zombieMaps } from './map-aliases.js';
|
||||||
|
|
||||||
function coverage(entries: BlueprintEntry[]) {
|
function coverage(entries: BlueprintEntry[]) {
|
||||||
@@ -29,7 +31,15 @@ function coverage(entries: BlueprintEntry[]) {
|
|||||||
const matches = zombieMaps(game).filter(map => map.canonical === server.map);
|
const matches = zombieMaps(game).filter(map => map.canonical === server.map);
|
||||||
return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug;
|
return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug;
|
||||||
}).map(server => server.name).sort();
|
}).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) };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
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 { randomUUID } from 'node:crypto';
|
||||||
import type { LaunchPreview } from '$lib/deployment-plan.js';
|
import type { LaunchPreview } from '$lib/deployment-plan.js';
|
||||||
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
|
import { blueprintRecipe, captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from './launch-recipes.js';
|
||||||
@@ -461,6 +461,7 @@ async function runDestructions(): Promise<void> {
|
|||||||
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 }[];
|
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) {
|
for (const operation of queued) {
|
||||||
if (creating || destroying.has(operation.server)) continue;
|
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);
|
destroying.add(operation.server);
|
||||||
try {
|
try {
|
||||||
if (operation.stage === REMOVING_SERVER) {
|
if (operation.stage === REMOVING_SERVER) {
|
||||||
@@ -470,7 +471,7 @@ async function runDestructions(): Promise<void> {
|
|||||||
finishDeployment(operation.id, { ok: false, message: 'A newer creation exists; local cleanup cannot safely resume.' });
|
finishDeployment(operation.id, { ok: false, message: 'A newer creation exists; local cleanup cannot safely resume.' });
|
||||||
continue;
|
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));
|
finishDeployment(operation.id, await cleanupDestroyedServer(operation.server, operation.id));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -484,20 +485,21 @@ async function runDestructions(): Promise<void> {
|
|||||||
}
|
}
|
||||||
// A fresh successful zero is required; cached occupancy and timeouts are not evidence of emptiness.
|
// A fresh successful zero is required; cached occupancy and timeouts are not evidence of emptiness.
|
||||||
const players = await rconGetPlayerCount(operation.server);
|
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) {
|
if (players !== 0) {
|
||||||
deploymentStage(operation.id, players === null
|
deploymentStage(operation.id, players === null
|
||||||
? 'Waiting for player count: server could not be reached'
|
? 'Waiting for player count: server could not be reached'
|
||||||
: 'Waiting for server to empty: ' + players + ' player(s) online');
|
: 'Waiting for server to empty: ' + players + ' player(s) online');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!claimRemoval(operation.id)) continue;
|
||||||
deploymentStage(operation.id, 'Server is empty (0 players verified)');
|
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);
|
const result = await destroyServerUnlocked(operation.server, operation.id);
|
||||||
finishDeployment(operation.id, result);
|
finishDeployment(operation.id, result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const row = db().prepare('SELECT state FROM server_deployments WHERE id = ?').get(operation.id) as { state: string };
|
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);
|
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); }
|
} finally { destroying.delete(operation.server); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,31 @@ export function getDeployment(id: string) {
|
|||||||
const collection = db().prepare(`SELECT c.id, c.name FROM server_collection_members m
|
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;
|
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,
|
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.
|
// This stage is written only after Komodo has confirmed deployment deletion.
|
||||||
export const REMOVING_SERVER = 'Removing server files and registry';
|
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 {
|
export function canResumeCleanup(id: string): boolean {
|
||||||
return !!db().prepare(`SELECT d.id FROM server_deployments d
|
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 = ?
|
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() {
|
export function listDeployments() {
|
||||||
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
|
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();
|
(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 {
|
export function interruptDeployments(): void {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
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 { existsSync } from 'node:fs';
|
||||||
import { dirname, join, posix, relative, sep } from 'node:path';
|
import { dirname, join, posix, relative, sep } from 'node:path';
|
||||||
import { configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
import { configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||||
@@ -244,6 +246,40 @@ export interface RunDriftInput {
|
|||||||
servers?: string[]; // if omitted, scans the whole fleet
|
servers?: string[]; // if omitted, scans the whole fleet
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
triggeredBy?: 'ui' | 'sweep' | 'cron';
|
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<DriftPlan> {
|
||||||
|
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<DriftPlan> {
|
||||||
|
const targets = driftTargets(servers);
|
||||||
|
const entries: DriftEntry[] = [];
|
||||||
|
for (const server of targets) entries.push(...await scanServerDrift(server));
|
||||||
|
return describePlan(targets, entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunDriftResult {
|
export interface RunDriftResult {
|
||||||
@@ -259,7 +295,7 @@ export interface RunDriftResult {
|
|||||||
|
|
||||||
function deploymentIdNow(): string {
|
function deploymentIdNow(): string {
|
||||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
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<RunDriftResult> {
|
export async function runDriftDeployment(input: RunDriftInput = {}): Promise<RunDriftResult> {
|
||||||
const { dryRun = false, triggeredBy = 'ui' } = input;
|
const { dryRun = false, triggeredBy = 'ui' } = input;
|
||||||
|
|
||||||
const scanTargets = input.servers && input.servers.length > 0
|
const scanTargets = driftTargets(input.servers);
|
||||||
? input.servers
|
|
||||||
: listServers().map((s) => s.name);
|
|
||||||
|
|
||||||
const perServer: Array<{
|
const perServer: Array<{
|
||||||
server: string;
|
server: string;
|
||||||
@@ -287,6 +321,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
|
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recheck identities, existing bytes, and replacement bytes before any write.
|
||||||
|
const plan = input.expectedFingerprint ? await describePlan(scanTargets, perServer.flatMap(server => server.entries)) : null;
|
||||||
|
if (plan && plan.fingerprint !== input.expectedFingerprint) throw new DriftPlanChanged(plan);
|
||||||
const totalDrift = perServer.reduce((sum, s) => sum + s.entries.length, 0);
|
const totalDrift = perServer.reduce((sum, s) => sum + s.entries.length, 0);
|
||||||
if (totalDrift === 0 || dryRun) {
|
if (totalDrift === 0 || dryRun) {
|
||||||
return {
|
return {
|
||||||
@@ -335,6 +372,14 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
const dest = destOfKind(s.server, entry.kind);
|
const dest = destOfKind(s.server, entry.kind);
|
||||||
const abs = join(dest, entry.relPath);
|
const abs = join(dest, entry.relPath);
|
||||||
try {
|
try {
|
||||||
|
const reviewed = plan?.files.find(file => 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') {
|
if (entry.driftKind === 'untracked') {
|
||||||
// Delete a file inventory doesn't know about.
|
// Delete a file inventory doesn't know about.
|
||||||
const content = await readFile(abs);
|
const content = await readFile(abs);
|
||||||
@@ -349,6 +394,10 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
|
s.skipped.push(`${entry.relPath}: missing artifact could not be resolved from the DB`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (reviewed && sha256Buffer(written) !== reviewed.afterSha256) {
|
||||||
|
s.skipped.push(`${entry.relPath}: promoted content changed after review; scan and review again`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const before = await readFile(abs).catch((err: NodeJS.ErrnoException) => {
|
const before = await readFile(abs).catch((err: NodeJS.ErrnoException) => {
|
||||||
if (err.code === 'ENOENT') return null;
|
if (err.code === 'ENOENT') return null;
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -35,17 +35,17 @@ export function fleetOperations(now = Date.now()): FleetOperation[] {
|
|||||||
// Only the newest lifecycle operation can still require action on a server.
|
// Only the newest lifecycle operation can still require action on a server.
|
||||||
const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d
|
const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d
|
||||||
WHERE d.state IN ('queued', 'running', 'waiting', 'awaiting-health')
|
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
|
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))`)
|
(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;
|
.all(since) as { id: string; server: string; action: string; state: string; stage: string;
|
||||||
error: string | null; started_at: string }[];
|
error: string | null; started_at: string }[];
|
||||||
for (const row of lifecycle) {
|
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';
|
: ['failed', 'interrupted'].includes(row.state) ? 'attention' : 'active';
|
||||||
operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment',
|
operations.push({ key: `deployment:${row.id}`, group: `deployment:${row.id}`, kind: 'deployment',
|
||||||
server: row.server, state, startedAt: row.started_at,
|
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,
|
: state === 'attention' ? `${row.action === 'create' ? 'Launch' : 'Removal'} needs attention` : row.stage,
|
||||||
detail: row.error ?? row.stage, href: `/deployments/${encodeURIComponent(row.id)}` });
|
detail: row.error ?? row.stage, href: `/deployments/${encodeURIComponent(row.id)}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -50,6 +50,13 @@
|
|||||||
let artifact = $state<Artifact | null>(null);
|
let artifact = $state<Artifact | null>(null);
|
||||||
let versions = $state<Version[]>([]);
|
let versions = $state<Version[]>([]);
|
||||||
let matchedServers = $state<string[]>([]);
|
let matchedServers = $state<string[]>([]);
|
||||||
|
let installedVersions = $state<Array<{ server: string; version: number; versionId: number; recordedAt: string }>>([]);
|
||||||
|
interface ScopeImpact { before: string[]; after: string[]; added: string[]; removed: string[]; affected: string[]; composed: boolean; detached: string[]; }
|
||||||
|
let scopeReviewOpen = $state(false);
|
||||||
|
let scopeImpact = $state<ScopeImpact | null>(null);
|
||||||
|
let scopePreviewError = $state('');
|
||||||
|
let scopePreviewLoading = $state(false);
|
||||||
|
let scopeComplete = $state<{ count: number; composed: boolean; detached: number; href: string } | null>(null);
|
||||||
let runningBetaId = $state<number | null>(null);
|
let runningBetaId = $state<number | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let flash = $state<string | null>(null);
|
let flash = $state<string | null>(null);
|
||||||
@@ -84,9 +91,24 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
const latestVersion = $derived(versions[0] ?? 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 {
|
function scopeLabel(s: Artifact): string {
|
||||||
if (!s.scope_type) return 'unscoped';
|
if (!s.scope_type) return 'unscoped';
|
||||||
@@ -105,7 +127,7 @@
|
|||||||
const body = await detailRes.json();
|
const body = await detailRes.json();
|
||||||
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
|
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
|
||||||
if (requestedId !== id || current !== request) return;
|
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 (scopes) scopeOptions = scopes;
|
||||||
if (seedForm) seedScopeForm();
|
if (seedForm) seedScopeForm();
|
||||||
loadError = '';
|
loadError = '';
|
||||||
@@ -135,10 +157,12 @@
|
|||||||
if (runningBetaId !== null) return;
|
if (runningBetaId !== null) return;
|
||||||
seedScopeForm();
|
seedScopeForm();
|
||||||
flash = null;
|
flash = null;
|
||||||
|
scopeReviewOpen = true;
|
||||||
scopeDialog?.showModal();
|
scopeDialog?.showModal();
|
||||||
}
|
}
|
||||||
function cancelScopeModal(): void {
|
function cancelScopeModal(): void {
|
||||||
seedScopeForm();
|
seedScopeForm();
|
||||||
|
scopeReviewOpen = false;
|
||||||
scopeDialog?.close();
|
scopeDialog?.close();
|
||||||
}
|
}
|
||||||
function openUploadModal(): void {
|
function openUploadModal(): void {
|
||||||
@@ -192,7 +216,7 @@
|
|||||||
|
|
||||||
async function saveScope(e: Event): Promise<void> {
|
async function saveScope(e: Event): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (updatingScope) return;
|
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
|
||||||
if (scopeType !== 'none' && !editedScopeValue) {
|
if (scopeType !== 'none' && !editedScopeValue) {
|
||||||
flash = `Pick a ${scopeType}`;
|
flash = `Pick a ${scopeType}`;
|
||||||
return;
|
return;
|
||||||
@@ -207,12 +231,13 @@
|
|||||||
scopeValue: scopeType === 'none' ? null : editedScopeValue
|
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) {
|
if (!res.ok || !body.ok) {
|
||||||
flash = body.error ?? `HTTP ${res.status}`;
|
flash = body.error ?? `HTTP ${res.status}`;
|
||||||
return;
|
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();
|
await load();
|
||||||
} catch (error) { flash = (error as Error).message; } finally {
|
} catch (error) { flash = (error as Error).message; } finally {
|
||||||
updatingScope = false;
|
updatingScope = false;
|
||||||
@@ -244,6 +269,8 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{artifact?.name ?? crumbLabel} · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
|
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => load()}>Retry</Button></div>{/if}
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading…</div>
|
<div class="loading">Loading…</div>
|
||||||
@@ -258,6 +285,9 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
|
||||||
|
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {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. <a href={scopeComplete.href}>Review reconciliation →</a></div>{/if}
|
||||||
|
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={`/betas/${runningBetaId}`}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
|
||||||
|
|
||||||
{#if runningBetaId}
|
{#if runningBetaId}
|
||||||
<div class="warn-banner">
|
<div class="warn-banner">
|
||||||
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is running for this {family}.
|
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is running for this {family}.
|
||||||
@@ -278,7 +308,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kv-grid">
|
<details class="file-settings"><summary>Target file, scope, and eligible servers</summary><div class="kv-grid">
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<span class="k">Target file</span>
|
<span class="k">Target file</span>
|
||||||
<span class="v mono">{a.target_rel_path}</span>
|
<span class="v mono">{a.target_rel_path}</span>
|
||||||
@@ -295,7 +325,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<span class="k">Applies to</span>
|
<span class="k">Eligible servers</span>
|
||||||
<span class="v mono">
|
<span class="v mono">
|
||||||
{#if !a.scope_type}
|
{#if !a.scope_type}
|
||||||
<span class="dim">Unscoped — deploy from a beta</span>
|
<span class="dim">Unscoped — deploy from a beta</span>
|
||||||
@@ -307,28 +337,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<span class="k">Current version</span>
|
<span class="k">Promoted version</span>
|
||||||
<span class="v mono">
|
<span class="v mono">
|
||||||
{#if currentVersion}
|
{#if currentVersion}
|
||||||
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(0, 8)}
|
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(0, 8)}
|
||||||
<Badge variant="online" size="sm">Promoted</Badge>
|
<Badge variant="online" size="sm">Promoted</Badge>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="dim">Never promoted — open a <a href="/betas">beta</a></span>
|
<span class="dim">Never promoted — test a version first</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if showLatestSeparately && latestVersion}
|
</div></details>
|
||||||
<div class="kv">
|
|
||||||
<span class="k">Latest version</span>
|
|
||||||
<span class="v mono">
|
|
||||||
{artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)}
|
|
||||||
<StatusBadge state={latestVersion.state} size="sm" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {new Date(installed.recordedAt).toLocaleString()}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
|
||||||
|
|
||||||
{#if a.kind === 'config' || a.kind === 'baseline'}
|
{#if a.kind === 'config' || a.kind === 'baseline'}
|
||||||
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
|
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
|
||||||
{runningBetaId} scopeType={a.scope_type} scopeValue={a.scope_value} onsaved={() => load({ seedForm: false })} />
|
{runningBetaId} scopeType={a.scope_type} scopeValue={a.scope_value} onsaved={() => load({ seedForm: false })} />
|
||||||
@@ -346,8 +369,6 @@
|
|||||||
{#each versions.slice(0,limit) as v (v.id)}
|
{#each versions.slice(0,limit) as v (v.id)}
|
||||||
<div class="version-row" class:current={a.current_version_id === v.id}>
|
<div class="version-row" class:current={a.current_version_id === v.id}>
|
||||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||||
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
|
|
||||||
<span class="v-size">{v.size} B</span>
|
|
||||||
<span class="v-notes">{v.notes ?? ''}
|
<span class="v-notes">{v.notes ?? ''}
|
||||||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -358,6 +379,7 @@
|
|||||||
<StatusBadge state={v.state} size="sm" />
|
<StatusBadge state={v.state} size="sm" />
|
||||||
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
|
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
<details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -374,10 +396,10 @@
|
|||||||
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
|
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{#if flash}<div class="alert">{flash}</div>{/if}
|
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
|
||||||
|
|
||||||
<!-- Scope dialog -->
|
<!-- Scope dialog -->
|
||||||
<dialog aria-label="Edit scope" oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
<dialog aria-label="Edit scope" onclose={() => scopeReviewOpen = false} oncancel={event => { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>
|
||||||
<form class="dlg" onsubmit={saveScope}>
|
<form class="dlg" onsubmit={saveScope}>
|
||||||
<header class="dlg-head">
|
<header class="dlg-head">
|
||||||
<h3>Edit scope</h3>
|
<h3>Edit scope</h3>
|
||||||
@@ -387,20 +409,21 @@
|
|||||||
<div class="scope-row">
|
<div class="scope-row">
|
||||||
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
<Select ariaLabel="Scope type" bind:value={scopeType} options={scopeTypeOptions} disabled={updatingScope} />
|
||||||
{#if scopeType === 'game' || scopeType === 'map'}
|
{#if scopeType === 'game' || scopeType === 'map'}
|
||||||
<Select ariaLabel="Scope game" bind:value={scopeGame} options={gameOptions} disabled={updatingScope} />
|
<Select ariaLabel="Scope game" bind:value={scopeGame} onchange={() => scopeMap = ''} options={gameOptions} disabled={updatingScope} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if scopeType === 'map'}
|
{#if scopeType === 'map'}
|
||||||
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
<Select ariaLabel="Scope map" bind:value={scopeMap} options={mapOptions} disabled={updatingScope || !scopeGame} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
{#if scopePreviewLoading}<p role="status">Reviewing eligible servers…</p>{:else if scopePreviewError}<p role="alert">{scopePreviewError}</p>{:else if scopeImpact}<section class="scope-impact" aria-label="Scope change review"><strong>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added · {scopeImpact.removed.length} removed</strong>{#if scopeImpact.added.length}<p>Added: {scopeImpact.added.join(', ')}</p>{/if}{#if scopeImpact.removed.length}<p>Removed: {scopeImpact.removed.join(', ')}</p>{/if}<p>{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.'}</p></section>{/if}
|
||||||
<div class="dlg-actions">
|
<div class="dlg-actions">
|
||||||
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}>Cancel</Button>
|
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}>Cancel</Button>
|
||||||
<Button type="submit" variant="primary" disabled={updatingScope} loading={updatingScope}>
|
<Button type="submit" variant="primary" disabled={updatingScope || scopePreviewLoading || !scopeImpact} loading={updatingScope}>
|
||||||
Save scope
|
Save reviewed scope
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
|
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
@@ -433,7 +456,7 @@
|
|||||||
Upload version
|
Upload version
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{#if flash}<div class="alert alert-danger">{flash}</div>{/if}
|
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +522,7 @@
|
|||||||
.version-list { display: flex; flex-direction: column; gap: var(--space-1); }
|
.version-list { display: flex; flex-direction: column; gap: var(--space-1); }
|
||||||
.version-row {
|
.version-row {
|
||||||
display: grid;
|
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);
|
gap: var(--space-3);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
@@ -514,7 +537,17 @@
|
|||||||
background: rgba(124, 212, 255, 0.04);
|
background: rgba(124, 212, 255, 0.04);
|
||||||
}
|
}
|
||||||
.v-num { color: var(--text-primary); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
.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 { 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 { 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); }
|
.v-notes a { display: block; color: var(--accent-primary-hover); margin-top: var(--space-2); }
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, betaPlanIssues, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
||||||
import { builtInPlans, requirement, requirementKinds, requirementLabels, parseBetaPlan, type BetaPlan, type RequirementKind } from '$lib/beta-plan.js';
|
|
||||||
import { coverageSummary, needsGameTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
|
import { coverageSummary, needsGameTelemetry, presetDescriptions, requirementCategory, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||||
let { plan = $bindable<BetaPlan>() }: { plan: BetaPlan } = $props();
|
let { plan = $bindable<BetaPlan>(), serverCount }: { plan: BetaPlan; serverCount?: number } = $props();
|
||||||
|
let validationRequested = $state(false), coverageOpen = $state(false), deadlineOpen = $state(false);
|
||||||
|
const issues = $derived(validationRequested ? betaPlanIssues(plan, serverCount) : []);
|
||||||
|
const fieldError = (field: string) => issues.find(issue => issue.field === field)?.message;
|
||||||
|
export async function validate(): Promise<boolean> {
|
||||||
|
validationRequested = true;
|
||||||
|
const issue = betaPlanIssues(plan, serverCount)[0];
|
||||||
|
if (!issue) return true;
|
||||||
|
if (issue.requirementId) editingId = issue.requirementId;
|
||||||
|
if (['quorum', 'waveSize'].includes(issue.field)) coverageOpen = true;
|
||||||
|
if (issue.field === 'timeoutHours') deadlineOpen = true;
|
||||||
|
await tick();
|
||||||
|
document.getElementById(`plan-${issue.field}`)?.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const targetLabel = (kind: RequirementKind) => ({ round:'Target round', matches:'Matches required', 'populated-minutes':'Populated minutes', 'player-hours':'Player-hours required', population:'Players required', 'sustained-population':'Consecutive minutes', 'healthy-minutes':'Consecutive healthy minutes', 'failure-budget':'Maximum health outages', recovery:'Recovery restarts', manual:'Checklist items' })[kind];
|
||||||
let presets = $state(builtInPlans);
|
let presets = $state(builtInPlans);
|
||||||
let presetName = $state('');
|
let presetName = $state('');
|
||||||
let addKind = $state<RequirementKind>('round');
|
let addKind = $state<RequirementKind>('round');
|
||||||
@@ -45,6 +59,7 @@
|
|||||||
message = `${requirementLabels[addKind]} added.`;
|
message = `${requirementLabels[addKind]} added.`;
|
||||||
}
|
}
|
||||||
async function savePreset() {
|
async function savePreset() {
|
||||||
|
if (!await validate()) return;
|
||||||
message = ''; error = ''; saving = true;
|
message = ''; error = ''; saving = true;
|
||||||
try {
|
try {
|
||||||
const validated = parseBetaPlan(plan);
|
const validated = parseBetaPlan(plan);
|
||||||
@@ -61,13 +76,13 @@
|
|||||||
|
|
||||||
<section class="builder" aria-label="Beta test plan">
|
<section class="builder" aria-label="Beta test plan">
|
||||||
<header class="section-heading">
|
<header class="section-heading">
|
||||||
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
|
<p>Define the checks each qualifying server must pass.</p>
|
||||||
<span class="pill">Manual promotion</span>
|
<span class="pill">Manual promotion</span>
|
||||||
|
<button type="button" class="secondary" aria-expanded={choosingPreset} aria-controls="beta-presets" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="section-heading"><p>Tailor what each server needs to prove.</p><Button size="sm" onclick={() => choosingPreset = !choosingPreset}>{choosingPreset ? 'Close presets' : 'Change preset'}</Button></div>
|
|
||||||
|
|
||||||
{#if choosingPreset}
|
{#if choosingPreset}
|
||||||
<div class="presets" role="group" aria-label="Start from a preset">
|
<div class="presets" id="beta-presets" role="group" aria-label="Start from a preset">
|
||||||
{#each builtInPlans as preset, index (preset.name)}
|
{#each builtInPlans as preset, index (preset.name)}
|
||||||
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
|
<button type="button" class="preset" class:selected={matchingPreset?.name === preset.name}
|
||||||
aria-pressed={matchingPreset?.name === preset.name} aria-label={`Use ${preset.name} preset`} onclick={() => applyPreset(preset.name)}>
|
aria-pressed={matchingPreset?.name === preset.name} aria-label={`Use ${preset.name} preset`} onclick={() => applyPreset(preset.name)}>
|
||||||
@@ -90,6 +105,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
|
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
|
||||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||||
|
{#if issues.length}<p class="error" role="alert">Correct {issues.length} highlighted {issues.length === 1 ? 'field' : 'fields'} before continuing.</p>{/if}
|
||||||
|
|
||||||
<div class="active-plan">
|
<div class="active-plan">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
@@ -118,20 +134,21 @@
|
|||||||
<summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary>
|
<summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary>
|
||||||
<div class="editor-fields">
|
<div class="editor-fields">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label>Description<input bind:value={r.label} maxlength="200" required /></label>
|
<label>Description<input id={`plan-${r.id}-label`} aria-invalid={!!fieldError(`${r.id}-label`)} aria-describedby={`error-${r.id}-label`} bind:value={r.label} maxlength="200" required /><span class="error" id={`error-${r.id}-label`}>{fieldError(`${r.id}-label`) ?? ''}</span></label>
|
||||||
{#if r.kind !== 'manual'}
|
{#if r.kind !== 'manual'}
|
||||||
<label>{r.kind === 'failure-budget' ? 'Maximum allowed' : 'Target'}
|
<label>{targetLabel(r.kind)}
|
||||||
<input type="number" min={r.kind === 'failure-budget' ? 0 : ['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 0.01 : 1} step={['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 'any' : 1} bind:value={r.target} required />
|
<input id={`plan-${r.id}-target`} aria-invalid={!!fieldError(`${r.id}-target`)} aria-describedby={`error-${r.id}-target`} type="number" min={r.kind === 'failure-budget' ? 0 : ['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 0.01 : 1} step={['healthy-minutes', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind) ? 'any' : 1} bind:value={r.target} required />
|
||||||
|
<span class="error" id={`error-${r.id}-target`}>{fieldError(`${r.id}-target`) ?? ''}</span>
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
|
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
|
||||||
<label>Minimum players<input type="number" min="1" step="1" bind:value={r.minPlayers} required /></label>
|
<label>Minimum players<input id={`plan-${r.id}-minPlayers`} aria-invalid={!!fieldError(`${r.id}-minPlayers`)} aria-describedby={`error-${r.id}-minPlayers`} type="number" min="1" step="1" bind:value={r.minPlayers} required /><span class="error" id={`error-${r.id}-minPlayers`}>{fieldError(`${r.id}-minPlayers`) ?? ''}</span></label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if r.kind === 'matches'}
|
{#if r.kind === 'matches'}
|
||||||
<label>Minimum round in the same game<input type="number" min="0" step="1" bind:value={r.minRound} required /></label>
|
<label>Minimum round in the same game<input id={`plan-${r.id}-minRound`} aria-invalid={!!fieldError(`${r.id}-minRound`)} aria-describedby={`error-${r.id}-minRound`} type="number" min="0" step="1" bind:value={r.minRound} required /><span class="error" id={`error-${r.id}-minRound`}>{fieldError(`${r.id}-minRound`) ?? ''}</span></label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if r.kind === 'matches'}
|
{#if r.kind === 'matches'}
|
||||||
<label>Minimum match duration (minutes)<input type="number" min="0" step="any" bind:value={r.minMinutes} required /></label>
|
<label>Minimum match duration (minutes)<input id={`plan-${r.id}-minMinutes`} aria-invalid={!!fieldError(`${r.id}-minMinutes`)} aria-describedby={`error-${r.id}-minMinutes`} type="number" min="0" step="any" bind:value={r.minMinutes} required /><span class="error" id={`error-${r.id}-minMinutes`}>{fieldError(`${r.id}-minMinutes`) ?? ''}</span></label>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if needsGameTelemetry(r)}
|
{#if needsGameTelemetry(r)}
|
||||||
@@ -157,7 +174,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="policy-settings">
|
<div class="policy-settings">
|
||||||
<details class="policy">
|
<details class="policy" bind:open={coverageOpen}>
|
||||||
<summary><span>Coverage & rollout<small>{coverageSummary(plan)} · {rolloutSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
<summary><span>Coverage & rollout<small>{coverageSummary(plan)} · {rolloutSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
||||||
<div class="policy-body">
|
<div class="policy-body">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
@@ -166,21 +183,21 @@
|
|||||||
<option value="map">At least one per selected map</option><option value="game">At least one per selected game</option>
|
<option value="map">At least one per selected map</option><option value="game">At least one per selected game</option>
|
||||||
<option value="map-game">At least one per selected game/map combination</option>
|
<option value="map-game">At least one per selected game/map combination</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
{#if plan.scope === 'quorum'}<label>Required servers<input type="number" min="1" step="1" bind:value={plan.quorum} required /></label>{/if}
|
{#if plan.scope === 'quorum'}<label>Required servers<input id="plan-quorum" aria-invalid={!!fieldError('quorum')} aria-describedby="error-quorum" type="number" min="1" max={serverCount ?? 1000} step="1" bind:value={plan.quorum} required /><span class="error" id="error-quorum">{fieldError('quorum') ?? `${serverCount ?? 0} servers selected`}</span></label>{/if}
|
||||||
<label>Servers per wave (0 = all at once)<input type="number" min="0" step="1" bind:value={plan.waveSize} required /></label>
|
<label>Servers per wave (0 = all at once)<input id="plan-waveSize" aria-invalid={!!fieldError('waveSize')} aria-describedby="error-waveSize" type="number" min="0" step="1" bind:value={plan.waveSize} required /><span class="error" id="error-waveSize">{fieldError('waveSize') ?? ''}</span></label>
|
||||||
</div>
|
</div>
|
||||||
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. Restarts always wait for empty servers.</p>
|
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. Restarts always wait for empty servers.</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="policy">
|
<details class="policy" bind:open={deadlineOpen}>
|
||||||
<summary><span>Deadline & failure handling<small>{plan.timeoutHours}-hour window · {failureSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
<summary><span>Deadline & failure handling<small>{plan.timeoutHours}-hour window · {failureSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
||||||
<div class="policy-body">
|
<div class="policy-body">
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<label>Testing window (hours from opening)<input type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /></label>
|
<label>Testing window (hours from opening)<input id="plan-timeoutHours" aria-invalid={!!fieldError('timeoutHours')} aria-describedby="error-timeoutHours" type="number" min="0.01" max="8760" step="any" bind:value={plan.timeoutHours} required /><span class="error" id="error-timeoutHours">{fieldError('timeoutHours') ?? ''}</span></label>
|
||||||
<label>On test failure<select bind:value={plan.failureAction}><option value="fail">Mark failed and hold</option><option value="pause">Pause for investigation</option><option value="rollback">Roll back automatically</option></select></label>
|
<label>On test failure<select bind:value={plan.failureAction}><option value="fail">Mark failed and hold</option><option value="pause">Pause for investigation</option><option value="rollback">Roll back automatically</option></select></label>
|
||||||
<label>Rollback restarts<select bind:value={plan.rollbackRestart}><option value="when-empty">Rolling restart when empty</option><option value="manual">Restore files; restart manually</option></select></label>
|
<label>Rollback restarts<select bind:value={plan.rollbackRestart}><option value="when-empty">Rolling restart when empty</option><option value="manual">Restore files; restart manually</option></select></label>
|
||||||
</div>
|
</div>
|
||||||
<p>Missing evidence at the deadline is inconclusive. Health and error checks continue until you manually promote.</p>
|
<p>Waiting for empty servers uses this window. Pausing and retrying do not extend it. Missing evidence at the deadline is inconclusive. Health checks continue until you manually promote.</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="policy save-policy">
|
<details class="policy save-policy">
|
||||||
@@ -195,8 +212,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.builder { display: grid; gap: 16px; }
|
.builder { display: grid; gap: 16px; }
|
||||||
h3, h4, h5, p, dl, dd { margin: 0; }
|
h4, h5, p, dl, dd { margin: 0; }
|
||||||
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
|
|
||||||
h4 { font-size: 19px; font-weight: 600; }
|
h4 { font-size: 19px; font-weight: 600; }
|
||||||
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
|
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
|
||||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||||
@@ -243,7 +259,7 @@
|
|||||||
.add-row label { flex: 1 1 220px; }
|
.add-row label { flex: 1 1 220px; }
|
||||||
label { display: grid; gap: 7px; flex: 1 1 180px; min-width: 0; font-size: 12px; color: var(--text-secondary); }
|
label { display: grid; gap: 7px; flex: 1 1 180px; min-width: 0; font-size: 12px; color: var(--text-secondary); }
|
||||||
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: 6px; padding: 10px 12px; min-width: 0; font: inherit; }
|
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: 6px; padding: 10px 12px; min-width: 0; font: inherit; }
|
||||||
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; }
|
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; border-color: var(--border-control); }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
button:disabled { opacity: .5; cursor: default; }
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
|
button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
|
||||||
|
|||||||
@@ -1,91 +1,103 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import ServerIdentity from './ServerIdentity.svelte';
|
import ServerIdentity from './ServerIdentity.svelte';
|
||||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||||
import ListFooter from './primitives/ListFooter.svelte';
|
import ListFooter from './primitives/ListFooter.svelte';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
import { coverageSummary } from '$lib/beta-plan-summary.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import type { BetaTestView } from '$lib/beta-plan.js';
|
import { coverageSummary, requirementSummary, needsGameTelemetry } from '$lib/beta-plan-summary.js';
|
||||||
let { test, betaId, closed, onUpdate }: { test: BetaTestView; betaId: number; closed: boolean; onUpdate: () => Promise<void> } = $props();
|
import { betaWaitingCondition, betaEvidenceSummary } from '$lib/beta-evidence.js';
|
||||||
|
import type { BetaRequirement, BetaTestView } from '$lib/beta-plan.js';
|
||||||
|
let { test, betaId, closed, deadline, retryBlocked, onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; onUpdate:()=>Promise<void> } = $props();
|
||||||
let notes = $state<Record<string,string>>({});
|
let notes = $state<Record<string,string>>({});
|
||||||
let evidenceOpen = $state(false);
|
let evidenceOpen = $state(false), evidenceLimit = $state(25), busy = $state(false), error = $state(''), message = $state('');
|
||||||
let evidenceLimit = $state(25);
|
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
|
||||||
let busy = $state(false);
|
let evidenceSection = $state<HTMLDetailsElement>();
|
||||||
let error = $state('');
|
const canSign = $derived(!closed && ['active','passed'].includes(test.outcome));
|
||||||
|
const expired = $derived(test.outcome === 'inconclusive' || (!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now()));
|
||||||
|
const ranked = $derived([...test.servers].sort((a,b) => (a.progress.some(r => r.failed) ? 0 : a.passed ? 2 : 1) - (b.progress.some(r => r.failed) ? 0 : b.passed ? 2 : 1) || a.server.localeCompare(b.server)));
|
||||||
|
const filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
|
||||||
|
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
|
||||||
|
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
|
||||||
|
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
|
||||||
|
function reportAge(at:string) { const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(at))/60000));return minutes<1?'less than a minute ago':minutes<60?`${minutes}m ago`:`${Math.floor(minutes/60)}h ${minutes%60}m ago`; }
|
||||||
async function action(action:string, extra:Record<string,string>={}) {
|
async function action(action:string, extra:Record<string,string>={}) {
|
||||||
busy = true; error = '';
|
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? new Date(deadline).toLocaleString() : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
|
||||||
|
busy = true; error = ''; message = '';
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/betas/${betaId}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...extra }) });
|
const response = await fetch(`/api/betas/${betaId}/test`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...extra})}), body = await response.json();
|
||||||
const body = await response.json();
|
if (!response.ok) throw new Error(body.error ?? 'Could not update the test.');
|
||||||
if (!response.ok) throw new Error(body.error);
|
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
|
message = action === 'sign-off' ? 'Observation recorded.' : action === 'retry' ? 'Fresh attempt queued. Deployed servers restart when empty; the deadline is unchanged.' : action === 'pause' ? 'Testing paused. The original deadline still applies.' : 'Testing resumed. Continuous timing checks restart from fresh observations.';
|
||||||
} catch(e) { error = (e as Error).message; }
|
} catch(e) { error = (e as Error).message; }
|
||||||
finally { busy = false; }
|
finally { busy = false; }
|
||||||
}
|
}
|
||||||
|
async function showEvidence(server:string, requirement:BetaRequirement) {
|
||||||
|
evidenceServer = server; evidenceKind = needsGameTelemetry(requirement) ? 'gameplay' : requirement.kind === 'manual' ? 'manual-sign-off' : ['failure-budget','healthy-minutes'].includes(requirement.kind) ? 'health' : '';
|
||||||
|
evidenceQuery = ''; evidenceOpen = true; await tick(); evidenceSection?.scrollIntoView({block:'start',behavior:'smooth'}); evidenceSection?.querySelector('summary')?.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section aria-label="Test plan progress">
|
<section aria-label="Test plan progress">
|
||||||
<div class="heading"><h2>Test plan</h2><StatusBadge state={test.outcome} /></div>
|
<div class="heading"><h2>Test results</h2><StatusBadge state={test.outcome}/></div>
|
||||||
{#if test.reason}<p>{test.reason}</p>{/if}
|
<p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p>
|
||||||
<p>{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion</p>
|
|
||||||
<details><summary>Test policy</summary><p>{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.'}</p></details>
|
|
||||||
{#if !closed}
|
{#if !closed}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if test.outcome === 'paused'}<Button size="sm" disabled={busy} onclick={() => action('resume')}>Resume</Button>{/if}
|
{#if test.outcome === 'paused' && !expired}<Button size="sm" disabled={busy} onclick={()=>action('resume')}>Resume testing</Button>{/if}
|
||||||
{#if ['active', 'passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('pause')}>Pause test</Button>{/if}
|
{#if ['active','passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={()=>action('pause')}>Pause testing</Button>{/if}
|
||||||
{#if ['paused', 'failed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={() => action('retry')}>Restart testing with fresh evidence</Button>{/if}
|
{#if ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
|
||||||
|
{#if expired}<p class="guidance">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.</p>
|
||||||
|
{:else if test.outcome === 'paused'}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if error}<p role="alert" class="failed">{error}</p>{/if}
|
{#if error}<p role="alert" class="failed">{error}</p>{/if}
|
||||||
{#if test.servers.length === 0}<p>Waiting for the first server restart. Evidence collection begins with that run.</p>{/if}
|
{#if message}<p role="status">{message}</p>{/if}
|
||||||
{#each test.servers as server (server.server)}
|
{#if !test.servers.length}<p>Waiting for the first server restart. Evidence collection begins with that run. The testing window is already running.</p>{/if}
|
||||||
<article>
|
{#each ranked as server (server.server)}
|
||||||
<h3><ServerIdentity name={server.server} /> <small>Recovery cycles: {server.phase}</small></h3>
|
<details class="server" open={!server.passed}>
|
||||||
|
<summary><ServerIdentity name={server.server}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
|
||||||
|
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={new Date(server.observation.lastGameplayAt).toLocaleString()}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} <a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a></p>{/if}
|
||||||
{#each server.progress as progress (progress.id)}
|
{#each server.progress as progress (progress.id)}
|
||||||
{@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
|
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
|
||||||
<div class="requirement" class:failed={progress.failed}>
|
{#if requirement}<div class="requirement" class:failed={progress.failed}>
|
||||||
<div><strong>{progress.passed ? '✓' : progress.failed ? '✕' : '○'} {progress.label}</strong><span>{progress.detail}</span></div>
|
<div class="heading"><strong><span class="result">{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'}</span> {requirementSummary(requirement)}</strong><span>{progress.detail}</span></div>
|
||||||
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(requirement.kind)}
|
{#if !progress.passed && !closed}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
|
||||||
<span>Minimum {requirement.minPlayers} player(s){requirement.minRound ? `, round ${requirement.minRound} in the same game` : ''}{requirement.minMinutes ? `, ${requirement.minMinutes} minutes per match` : ''}</span>
|
{#if !['manual','failure-budget'].includes(requirement.kind)}<progress aria-label={requirementSummary(requirement)} value={Math.min(progress.current,progress.target)} max={progress.target}></progress>{/if}
|
||||||
{/if}
|
|
||||||
{#if !['manual', 'failure-budget'].includes(requirement.kind)}
|
|
||||||
<progress aria-label={progress.label} value={Math.min(progress.current, progress.target)} max={progress.target}></progress>
|
|
||||||
{/if}
|
|
||||||
{#if requirement.kind === 'manual' && !progress.passed && !closed}
|
{#if requirement.kind === 'manual' && !progress.passed && !closed}
|
||||||
<div class="actions">
|
{#if canSign && server.observingAt}<div class="signoff"><label>Tester name and observation<textarea aria-label={`Tester and observation for ${progress.label} on ${server.server}`} rows="2" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} placeholder="Name · what you checked and observed"></textarea></label><Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={()=>action('sign-off',{server:server.server,requirementId:progress.id,note:notes[`${server.server}:${progress.id}`]})}>Sign off</Button></div>
|
||||||
<input aria-label={`Tester and observation for ${progress.label} on ${server.server}`} placeholder="Tester name and observation" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} />
|
{:else if canSign}<p>Waiting for this server's restart and health verification to finish before tester sign-off.</p>
|
||||||
<Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={() => action('sign-off', { server: server.server, requirementId: progress.id, note: notes[`${server.server}:${progress.id}`] })}>Sign off</Button>
|
{:else}<p>Sign-off is unavailable while this test is {test.outcome}. {test.outcome === 'paused' ? 'Resume testing first.' : 'Use the recovery instructions above.'}</p>{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<button class="evidence-link" onclick={()=>showEvidence(server.server,requirement)}>View relevant evidence</button>
|
||||||
|
</div>{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</article>
|
</details>
|
||||||
{/each}
|
{/each}
|
||||||
<details ontoggle={event => evidenceOpen = event.currentTarget.open}>
|
<details><summary>Test policy</summary><p>{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.</p></details>
|
||||||
|
<details bind:this={evidenceSection} bind:open={evidenceOpen} class="evidence-section">
|
||||||
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
|
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
|
||||||
{#if evidenceOpen}
|
{#if evidenceOpen}
|
||||||
{#each test.evidence.slice(0,evidenceLimit) as event (event.id)}
|
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
|
||||||
<div class="evidence"><strong>{event.server} · {event.kind}</strong><time>{new Date(event.received_at).toLocaleString()}</time><pre>{event.payload}</pre></div>
|
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{server}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
|
||||||
{/each}
|
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><ServerIdentity name={event.server}/><time datetime={event.received_at}>{new Date(event.received_at).toLocaleString()}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
|
||||||
<ListFooter total={test.evidence.length} bind:limit={evidenceLimit} label="events" />
|
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
|
||||||
{#if !test.evidence.length}<p>No evidence recorded yet.</p>{/if}
|
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
section { display: grid; gap: 16px; }
|
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size:18px; }
|
||||||
h2, h3, p { margin: 0; }
|
p,span,time { font-size:13px; line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
|
||||||
h2 { font-size: 18px; } h3 { font-size: 15px; }
|
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
|
||||||
p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
|
||||||
.heading, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size:14px; } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
|
||||||
.heading { justify-content: space-between; }
|
.requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; }
|
||||||
article { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); }
|
||||||
.requirement { display: grid; gap: 8px; margin-top: 16px; }
|
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size:12px; }
|
||||||
.requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
|
||||||
progress { width: 100%; height: 8px; accent-color: var(--state-online); }
|
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
|
||||||
input { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; }
|
.filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; }
|
||||||
input { flex: 1; min-width: 160px; } summary { cursor: pointer; }
|
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; } .evidence-section > p { margin-top:12px; }
|
||||||
.failed { color: var(--state-danger); }
|
|
||||||
.evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
|
|
||||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let total = $derived(rows.reduce((sum, row) => sum + (Number(row.count) || 0), 0));
|
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 wasOpen = false;
|
||||||
let session = 0;
|
let session = 0;
|
||||||
let nextKey = 0;
|
let nextKey = 0;
|
||||||
@@ -74,19 +77,20 @@
|
|||||||
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
|
<form onsubmit={event => { event.preventDefault(); void save(true); }}>
|
||||||
<div class="identity"><label>Blueprint name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
|
<div class="identity"><label>Blueprint name<input bind:value={name} maxlength="80" placeholder="Friday Zombies" required disabled={busy} /></label><label>Description (optional)<input bind:value={description} maxlength="500" placeholder="Our weekend lineup" disabled={busy} /></label></div>
|
||||||
<div class="lineup">
|
<div class="lineup">
|
||||||
<div class="section-title"><h3>Your lineup</h3><span class="count">{total} server{total === 1 ? '' : 's'}</span></div>
|
<div class="section-title"><h3>Your lineup</h3><span class="count" class:error={total > 100} role="status">{total} / 100 target lobbies</span></div>
|
||||||
{#each rows as row, index (row.key)}
|
{#each rows as row, index (row.key)}
|
||||||
<div class="map-row">
|
<div class="map-row">
|
||||||
<span class="number">{String(index + 1).padStart(2, '0')}</span>
|
<span class="number">{String(index + 1).padStart(2, '0')}</span>
|
||||||
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
<label>Game<select aria-label={`Game ${index + 1}`} bind:value={row.game} disabled={busy} onchange={event => row.mapSlug = catalogs[event.currentTarget.value as ZombieGame][0]?.slug ?? ''}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||||
<label>Map<select aria-label={`Map ${index + 1}`} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label>
|
<label>Map<select aria-label={`Map ${index + 1}`} aria-invalid={duplicate(row) || undefined} aria-describedby={duplicate(row) ? `map-error-${row.key}` : undefined} bind:value={row.mapSlug} disabled={busy}>{#each catalogs[row.game] as map}<option value={map.slug} disabled={rows.some(other => other.key !== row.key && other.game === row.game && other.mapSlug === map.slug)}>{map.friendly}</option>{/each}</select>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}>Already selected. Choose another map.</span>{/if}</label>
|
||||||
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /></label>
|
<label>Target lobbies<input aria-label={`Lobbies ${index + 1}`} aria-invalid={!(Number.isInteger(row.count) && row.count >= 1 && row.count <= 20) || undefined} aria-describedby={`count-hint-${row.key}`} type="number" min="1" max="20" step="1" bind:value={row.count} required disabled={busy} /><span id={`count-hint-${row.key}`}>1–20</span></label>
|
||||||
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
|
<button type="button" class="remove" aria-label={`Remove map ${index + 1}`} disabled={busy || rows.length === 1} onclick={() => rows = rows.filter(item => item.key !== row.key)}>×</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<Button disabled={busy || rows.length >= 40} onclick={addMap}>+ Add another map</Button>
|
<Button disabled={busy || !canAdd} onclick={addMap}>+ Add another map</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">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.</p>
|
<p class="muted">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.</p>
|
||||||
|
{#if reason}<p class="validation" role="status">{reason}</p>{/if}
|
||||||
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
|
<div class="actions"><Button disabled={busy || !valid} onclick={() => save(false)}>Save blueprint</Button><Button type="submit" variant="primary" disabled={busy || !valid} loading={busy}>Save & preview missing servers →</Button></div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -94,11 +98,12 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: 14px; line-height: 1.6; }
|
||||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
|
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
|
||||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
|
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
|
||||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
|
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
|
||||||
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
|
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||||
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
|
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.builder { padding: 20px; }
|
.builder { padding: 20px; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||||
import ConfigInspector from './ConfigInspector.svelte';
|
import ConfigInspector from './ConfigInspector.svelte';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
|
import { configWarnings } from '$lib/config-workbench.js';
|
||||||
interface Version { id: number; version: number; notes: string | null; }
|
interface Version { id: number; version: number; notes: string | null; }
|
||||||
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
|
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
|
||||||
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
|
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
let loadedVersion = $state('');
|
let loadedVersion = $state('');
|
||||||
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
|
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
|
||||||
const dirty = $derived(text !== original || notes.trim().length > 0);
|
const dirty = $derived(text !== original || notes.trim().length > 0);
|
||||||
|
const ignoredWarnings = $derived(configWarnings(text, kind === 'config').filter(warning => warning.includes('ignored') || warning.includes('no value')));
|
||||||
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
|
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
selected = String(versions[0]?.id ?? ''); ready = true;
|
selected = String(versions[0]?.id ?? ''); ready = true;
|
||||||
@@ -117,7 +119,7 @@
|
|||||||
{:else if loadedVersion}
|
{:else if loadedVersion}
|
||||||
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
|
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
|
||||||
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
|
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
|
||||||
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
|
<div class="actions ui-sticky-actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}{#if ignoredWarnings.length}<strong class="save-warning">{ignoredWarnings.length} ignored or incomplete lines — review the open configuration notes before saving.</strong>{/if}</span>
|
||||||
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
|
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
|
||||||
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
|
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,8 +146,10 @@
|
|||||||
.actions { margin-top: 16px; }
|
.actions { margin-top: 16px; }
|
||||||
.actions > span { flex: 1 1 200px; }
|
.actions > span { flex: 1 1 200px; }
|
||||||
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
.save-warning { display: block; color: var(--state-warning); font-weight: 500; margin-top: 6px; }
|
||||||
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
|
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
|
||||||
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
|
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
|
||||||
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
|
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
|
||||||
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
|
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
|
||||||
|
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
|
||||||
import Button from './primitives/Button.svelte';
|
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 { artifactId, draftText, scopeType, scopeValue, fixedServer }: { fixedServer?: string; artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
|
||||||
let servers = $state<ConfigServer[]>([]);
|
let servers = $state<ConfigServer[]>([]);
|
||||||
let selected = $state('');
|
let selected = $state('');
|
||||||
let inspection = $state<ConfigInspection | null>(null);
|
let inspection = $state<ConfigInspection | null>(null);
|
||||||
|
let promoted = $state<ConfigInspection | null>(null);
|
||||||
|
let expanded = $state<string[]>([]);
|
||||||
|
let inspectedServer = '';
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
@@ -14,10 +18,15 @@
|
|||||||
let raw = $state(false);
|
let raw = $state(false);
|
||||||
let reload = $state(0);
|
let reload = $state(0);
|
||||||
let ready = $state(false);
|
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 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 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 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 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() {
|
async function loadServers() {
|
||||||
loading = true; error = '';
|
loading = true; error = '';
|
||||||
@@ -30,8 +39,9 @@
|
|||||||
} catch (err) { error = (err as Error).message; }
|
} catch (err) { error = (err as Error).message; }
|
||||||
finally { loading = false; }
|
finally { loading = false; }
|
||||||
}
|
}
|
||||||
onMount(loadServers);
|
onMount(() => { desiredServer = new URLSearchParams(window.location.search).get('server') ?? ''; void loadServers(); });
|
||||||
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible[0]?.name ?? ''; });
|
$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(() => {
|
$effect(() => {
|
||||||
const server = selected;
|
const server = selected;
|
||||||
const text = draftText;
|
const text = draftText;
|
||||||
@@ -39,7 +49,8 @@
|
|||||||
const refresh = reload;
|
const refresh = reload;
|
||||||
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
|
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
|
||||||
const controller = new AbortController();
|
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 () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, {
|
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();
|
const body = await response.json();
|
||||||
if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.');
|
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; }
|
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
|
||||||
finally { if (!controller.signal.aborted) loading = false; }
|
finally { if (!controller.signal.aborted) loading = false; }
|
||||||
}, text === undefined ? 0 : 350);
|
}, text === undefined ? 0 : 350);
|
||||||
@@ -71,26 +82,26 @@
|
|||||||
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
|
{#each eligible as server}<option value={server.name}>{serverTitle(server.name)}</option>{/each}
|
||||||
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
{#if !eligible.length}<option value="">No matching servers</option>{/if}
|
||||||
</select></label>
|
</select></label>
|
||||||
{#if error}<div class="error" role="alert">{error}</div>
|
{#if error}<div class="error" role="alert">{error}{inspection ? ' Showing the last completed preview.' : ''}</div>{/if}
|
||||||
{:else if loading}<p class="empty" role="status">Composing configuration…</p>
|
<p class="preview-status" role="status">{loading ? inspection ? 'Updating preview… Showing the last completed result.' : 'Composing configuration…' : inspection ? 'Preview up to date.' : ''}</p>
|
||||||
{:else if !eligible.length}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
|
{#if !eligible.length && !loading}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
|
||||||
{:else if inspection?.reason}<p class="empty">{inspection.reason}</p>
|
{:else if inspection?.reason}<p class="empty">{inspection.reason}</p>
|
||||||
{:else if inspection}
|
{:else if inspection}
|
||||||
<div class="sources" aria-label="Composition sources">
|
<ol class="sources" aria-label="Composition order, later layers take precedence">
|
||||||
{#each inspection.sources as source, index}
|
{#each inspection.sources as source, index}
|
||||||
{#if index}<span class="arrow" aria-hidden="true">→</span>{/if}
|
<li><span class="layer-number">{index + 1}.</span>{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
|
||||||
{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
|
{:else}<span class="map-source">{source.name}</span>{/if}<span>{overriddenCount(source)} settings superseded later</span></li>
|
||||||
{:else}<span class="map-source">{source.name}</span>{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</ol>
|
||||||
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
|
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
|
||||||
|
{#if promoted}<p class="change-summary">Compared with promoted settings: {changes.length} changed or added, {removed.length} removed.</p>{#if removed.length}<details><summary>Removed settings</summary><ul>{#each removed as setting}<li><code>{setting.key}</code> · previously {setting.value}</li>{/each}</ul></details>{/if}{/if}
|
||||||
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
|
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
|
||||||
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
|
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
|
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
{#each settings as setting (setting.key)}
|
{#each settings as setting (setting.key)}
|
||||||
<details class="setting"><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span></summary>
|
<details class="setting" open={expanded.includes(setting.key)} ontoggle={event => { const open = event.currentTarget.open; expanded = open ? [...new Set([...expanded, setting.key])] : expanded.filter(key => key !== setting.key); }}><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span>{#if promoted && previousValues.get(setting.key) !== setting.value}<span class="change-summary">{previousValues.has(setting.key) ? `Changed from ${previousValues.get(setting.key)}` : 'Added by draft'}</span>{/if}</summary>
|
||||||
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
|
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
|
||||||
</details>
|
</details>
|
||||||
{:else}<p class="empty">No matching settings.</p>{/each}
|
{:else}<p class="empty">No matching settings.</p>{/each}
|
||||||
@@ -110,10 +121,14 @@
|
|||||||
.server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
|
.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, 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; }
|
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, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; }
|
||||||
.sources a.draft { color: var(--state-warning); }
|
.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; }
|
.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; }
|
.toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; }
|
||||||
.switch { display: flex; gap: 4px; }
|
.switch { display: flex; gap: 4px; }
|
||||||
@@ -142,4 +157,5 @@
|
|||||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
|
.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); }
|
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
|
||||||
@media (max-width: 600px) { .inspector { padding: 16px; } }
|
@media (max-width: 600px) { .inspector { padding: 16px; } }
|
||||||
|
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import Tabs from './primitives/Tabs.svelte';
|
import Tabs from './primitives/Tabs.svelte';
|
||||||
import ListFooter from './primitives/ListFooter.svelte';
|
import ListFooter from './primitives/ListFooter.svelte';
|
||||||
import { mapLabel } from '$lib/script-library.js';
|
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 SectionHeader from './primitives/SectionHeader.svelte';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
import Badge from './primitives/Badge.svelte';
|
import Badge from './primitives/Badge.svelte';
|
||||||
import EmptyState from './primitives/EmptyState.svelte';
|
import EmptyState from './primitives/EmptyState.svelte';
|
||||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||||
import ConfigInspector from './ConfigInspector.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 Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
|
||||||
interface Artifact {
|
interface Artifact {
|
||||||
@@ -27,7 +28,11 @@
|
|||||||
let status = $state('');
|
let status = $state('');
|
||||||
let view = $state('library');
|
let view = $state('library');
|
||||||
let limit = $state(25);
|
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 dialog: HTMLDialogElement;
|
||||||
let kind = $state<'baseline' | 'config'>('config');
|
let kind = $state<'baseline' | 'config'>('config');
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
@@ -67,7 +72,7 @@
|
|||||||
} catch (err) { error = (err as Error).message; }
|
} catch (err) { error = (err as Error).message; }
|
||||||
finally { loading = false; }
|
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 openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); }
|
||||||
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
|
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
|
||||||
async function importFile(event: Event) {
|
async function importFile(event: Event) {
|
||||||
@@ -102,20 +107,20 @@
|
|||||||
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes. Inspect the settings applied to a server.">
|
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes. Inspect the settings applied to a server.">
|
||||||
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
|
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>
|
{#if artifacts.length}<div class="ui-summary"><span><strong>{baselines.length}</strong> baselines</span><span><strong>{overlays.length}</strong> overlays</span><span><strong>{pending}</strong> awaiting promotion</span></div>{/if}
|
||||||
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
|
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
|
||||||
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
|
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
|
||||||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||||
{#if view === 'inspect'}
|
{#if view === 'inspect'}
|
||||||
<ConfigInspector />
|
<ConfigInspector />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="filters">
|
{#if loading || artifacts.length || error}<div class="filters">
|
||||||
<input class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
|
<input class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
|
||||||
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
|
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
|
||||||
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
|
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
|
||||||
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
|
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
|
||||||
{#if query || game || scope || status}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
{#if query || game || scope || status}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
||||||
</div>
|
</div>{/if}
|
||||||
{#if loading}<div class="loading" role="status">Loading your config library…</div>
|
{#if loading}<div class="loading" role="status">Loading your config library…</div>
|
||||||
{:else if !artifacts.length && !error}
|
{:else if !artifacts.length && !error}
|
||||||
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
|
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
|
||||||
@@ -218,4 +223,5 @@
|
|||||||
.card-footer { padding-top:12px; margin-top:12px; }
|
.card-footer { padding-top:12px; margin-top:12px; }
|
||||||
.welcome { padding:24px; }
|
.welcome { padding:24px; }
|
||||||
.loading { padding:24px 0; }
|
.loading { padding:24px 0; }
|
||||||
|
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
|
import { addConfigSetting, changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
|
||||||
|
import Button from './primitives/Button.svelte';
|
||||||
let { text = $bindable(''), overlay = false, disabled = false }: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
|
let { text = $bindable(''), overlay = false, disabled = false }: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
|
||||||
let mode = $state<'raw' | 'settings'>('raw');
|
let mode = $state<'raw' | 'settings'>('raw');
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
|
let newKey = $state('');
|
||||||
|
let newValue = $state('');
|
||||||
|
let addError = $state('');
|
||||||
|
function addSetting() {
|
||||||
|
try { text = addConfigSetting(text, newKey, newValue); search = ''; newKey = ''; newValue = ''; addError = ''; }
|
||||||
|
catch (error) { addError = (error as Error).message; }
|
||||||
|
}
|
||||||
|
function addOnEnter(event: KeyboardEvent) { if (event.key === 'Enter') { event.preventDefault(); if (!disabled && newKey.trim() && newValue.trim()) addSetting(); } }
|
||||||
const entries = $derived(configLines(text));
|
const entries = $derived(configLines(text));
|
||||||
const visible = $derived(entries.filter(e => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase())));
|
const visible = $derived(entries.filter(e => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase())));
|
||||||
const warnings = $derived(configWarnings(text, overlay));
|
const warnings = $derived(configWarnings(text, overlay));
|
||||||
@@ -26,12 +35,14 @@
|
|||||||
<label class="setting"><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
|
<label class="setting"><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
|
||||||
<input value={entry.value} {disabled} aria-label={`${entry.key}, line ${entry.index + 1}`} oninput={e => text = changeConfigValue(text, entry.index, e.currentTarget.value)} />
|
<input value={entry.value} {disabled} aria-label={`${entry.key}, line ${entry.index + 1}`} oninput={e => text = changeConfigValue(text, entry.index, e.currentTarget.value)} />
|
||||||
</label>
|
</label>
|
||||||
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add set KEY VALUE lines in the raw editor to get started.'}</p>{/each}
|
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add your first setting below.'}</p>{/each}
|
||||||
|
<div class="add-setting"><label>Setting name<input bind:value={newKey} onkeydown={addOnEnter} placeholder="g_inactivity" {disabled} /></label><label>Value<input bind:value={newValue} onkeydown={addOnEnter} placeholder="0" {disabled} /></label><Button size="sm" onclick={addSetting} disabled={disabled || !newKey.trim() || !newValue.trim()}>Add setting</Button></div>
|
||||||
|
{#if addError}<p class="warnings" role="alert">{addError}</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if warnings.length}
|
{#if warnings.length}
|
||||||
<details class="warnings"><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'}</summary>
|
<details class="warnings" open={warnings.some(w => w.includes('ignored') || w.includes('no value'))}><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} — review before saving</summary>
|
||||||
<ul>{#each warnings as warning}<li>{warning}</li>{/each}</ul>
|
<ul>{#each warnings as warning}<li>{warning}</li>{/each}</ul>
|
||||||
</details>
|
</details>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -50,6 +61,8 @@
|
|||||||
input { box-sizing: border-box; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 12px; font: var(--text-sm) var(--font-mono); width: 100%; }
|
input { box-sizing: border-box; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 12px; font: var(--text-sm) var(--font-mono); width: 100%; }
|
||||||
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
|
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
|
||||||
.setting span { min-width: 0; overflow-wrap: anywhere; }
|
.setting span { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.add-setting { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; border-top: 1px solid var(--border-strong); padding-top: 16px; }
|
||||||
|
.add-setting label { flex: 1 1 140px; display: grid; gap: 6px; font-size: var(--text-sm); }
|
||||||
code { font: var(--text-sm) var(--font-mono); }
|
code { font: var(--text-sm) var(--font-mono); }
|
||||||
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
|
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
|
||||||
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
|
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
@@ -57,4 +70,5 @@
|
|||||||
summary { cursor: pointer; }
|
summary { cursor: pointer; }
|
||||||
li { margin: 6px 0; }
|
li { margin: 6px 0; }
|
||||||
@media (max-width: 600px) { .setting { grid-template-columns: 1fr; gap: 8px; } .toolbar > .hint { display: none; } }
|
@media (max-width: 600px) { .setting { grid-template-columns: 1fr; gap: 8px; } .toolbar > .hint { display: none; } }
|
||||||
|
input:not([type='checkbox']):not([type='file']), textarea { border-color: var(--border-control); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
@@ -14,28 +15,41 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let wasOpen = false;
|
let wasOpen = false;
|
||||||
let session = 0;
|
let session = 0;
|
||||||
|
let keyNotice = $state('');
|
||||||
|
let now = $state(Date.now());
|
||||||
|
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
||||||
|
const keysReady = $derived(preview?.servers.filter(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())).length ?? 0);
|
||||||
|
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||||||
let canLaunch = $derived(preview?.ready && preview.servers.length > 0 && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
let canLaunch = $derived(preview?.ready && preview.servers.length > 0 && preview.servers.every(server => server.name && (server.hasSavedKey || keys[server.name]?.trim())));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!dialog) return;
|
if (!dialog) return;
|
||||||
if (open && !wasOpen) { wasOpen = true; dialog.showModal(); void review(); }
|
if (open && !wasOpen) { wasOpen = true; keys = {}; preview = null; keyNotice = ''; dialog.showModal(); void review(); }
|
||||||
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
else if (!open && wasOpen) { wasOpen = false; session++; dialog.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
async function review() {
|
async function review() {
|
||||||
const current = ++session;
|
const current = ++session;
|
||||||
busy = true; error = ''; preview = null; keys = {};
|
const previous = preview;
|
||||||
|
busy = true; error = ''; keyNotice = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId }) });
|
const res = await fetch('/api/deployment-collections/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprintId }) });
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
|
if (!res.ok) throw new Error(body.error ?? 'Could not preview the collection.');
|
||||||
if (current === session) preview = body;
|
if (current === session) {
|
||||||
|
const next = body as CollectionPreview;
|
||||||
|
const names = new Set(next.servers.map(server => server.name));
|
||||||
|
keys = Object.fromEntries(next.servers.filter(server => server.name).map(server => [server.name!, keys[server.name!] ?? '']));
|
||||||
|
if (previous && (previous.servers.length !== next.servers.length || previous.servers.some(server => !names.has(server.name)))) keyNotice = 'Server assignments changed. Keys were retained only for the exact IDs still shown below. Review the new assignments.';
|
||||||
|
else if (previous) keyNotice = 'Preview refreshed. Entered keys are retained for these same server IDs.';
|
||||||
|
preview = next; now = Date.now();
|
||||||
|
}
|
||||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||||
finally { if (current === session) busy = false; }
|
finally { if (current === session) busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function launch() {
|
async function launch() {
|
||||||
if (!preview || !canLaunch || busy) return;
|
if (!preview || !canLaunch || expired || busy) return;
|
||||||
busy = true; error = '';
|
busy = true; error = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id, keys }) });
|
const res = await fetch('/api/deployment-collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ previewId: preview.id, keys }) });
|
||||||
@@ -46,6 +60,11 @@
|
|||||||
} catch (err) { error = (err as Error).message; }
|
} catch (err) { error = (err as Error).message; }
|
||||||
finally { busy = false; }
|
finally { busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function nextMissing() {
|
||||||
|
await tick();
|
||||||
|
dialog?.querySelector<HTMLInputElement>('input[data-missing="true"]')?.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
<dialog bind:this={dialog} aria-label="Launch collection" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||||
@@ -54,9 +73,12 @@
|
|||||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||||
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
|
{#if !preview}<p class="muted">{busy ? 'Finding a lobby and port for every server…' : 'Refresh to prepare another preview.'}</p>{#if !busy}<Button onclick={review}>Refresh preview</Button>{/if}
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||||
|
{#if expired}<p class="error" role="status">This preview expired. Refresh before launch; keys remain entered for unchanged IDs.</p>{/if}
|
||||||
<div class="lineup"><strong>{preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'}</strong>
|
<div class="lineup"><strong>{preview.servers.length ? `${preview.servers.length} missing server${preview.servers.length === 1 ? '' : 's'} to create` : 'Blueprint already satisfied'}</strong>
|
||||||
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
|
<table><thead><tr><th>Map</th><th>Target</th><th>Existing / queued</th><th>To create</th></tr></thead><tbody>{#each preview.entries as entry}<tr><th>{entry.recipe.mapName} <small>{entry.recipe.game.toUpperCase()}</small></th><td>{entry.count}</td><td title={entry.existingServers.join(', ')}>{entry.existing}</td><td class="quantity">{entry.missing}</td></tr>{/each}</tbody></table>
|
||||||
<p class="muted">Existing and queued servers count toward each map’s target, including stopped servers. Extra servers are kept. Servers awaiting removal do not count.</p>
|
<p class="muted">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.</p>
|
||||||
|
{#if preview.entries.some(entry => entry.existing)}<details class="coverage" open={!preview.servers.length}><summary>Review existing servers</summary>{#each preview.entries.filter(entry => entry.existing) as entry}<div class="coverage-map"><strong>{entry.recipe.mapName}: {entry.existing} existing</strong><p class="muted">{[...new Set((entry.coverage ?? []).map(server => server.health))].map(health => `${entry.coverage.filter(server => server.health === health).length} ${health}`).join(', ')}</p><ul>{#each entry.coverage ?? [] as server}<li><a href={server.href}><ServerIdentity name={server.name} /></a><span>{server.health === 'online' ? 'Healthy' : server.health}</span></li>{/each}</ul></div>{/each}</details>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if preview.servers.length}
|
{#if preview.servers.length}
|
||||||
<div class="pacing-note">
|
<div class="pacing-note">
|
||||||
@@ -70,15 +92,15 @@
|
|||||||
<p class="muted">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.</p>
|
<p class="muted">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.</p>
|
||||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||||
<ol class="servers" aria-label="Server launch order">
|
<ol class="servers" aria-label="Server launch order">
|
||||||
{#each preview.servers as server, index}
|
{#each preview.servers as server, index (server.name ?? `unavailable-${index}`)}
|
||||||
<li class="server-row">
|
<li class="server-row">
|
||||||
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
<span class="order" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||||
<div class="server-identity"><strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
<div class="server-identity">{#if index === 0 || preview.servers[index - 1].entryIndex !== server.entryIndex}<span class="map-group">{preview.entries[server.entryIndex].recipe.game.toUpperCase()} · {preview.entries[server.entryIndex].recipe.mapName}</span>{/if}<strong>{#if server.name}<ServerIdentity name={server.name} />{:else}{preview.entries[server.entryIndex].recipe.mapName}{/if}</strong><span class="server-id">{server.name ?? 'No available lobby'} · {server.port === null ? 'No available port' : `Port ${server.port}`}</span></div>
|
||||||
{#if server.name}<label><span>{server.hasSavedKey ? 'Saved key ready' : 'Server key required'}</span><input aria-label={`Key for ${server.name}`} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder={server.hasSavedKey ? 'Leave blank to reuse' : 'Paste this lobby’s key'} /></label>{/if}
|
{#if server.name}<label><span>{server.hasSavedKey ? 'Saved key available' : 'Server key required'}</span><input aria-label={`Key for ${server.name}`} data-missing={!server.hasSavedKey && !keys[server.name]?.trim()} type="password" bind:value={keys[server.name]} disabled={busy} maxlength="512" autocomplete="off" placeholder={server.hasSavedKey ? 'Leave blank to reuse' : 'Paste this server’s key'} /></label>{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
<div class="launch-footer"><p class="caption">Follow every server from queued to ready.<br />Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
|
<div class="launch-footer"><div><p role="status"><strong>{keysReady} / {preview.servers.length} keys available</strong></p><p class="caption">Validity is checked at startup. Preview expires at {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.</p>{#if keysReady < preview.servers.length}<button class="text-button" type="button" onclick={nextMissing}>Jump to next missing key</button>{/if}</div><div class="actions"><Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button><Button type="submit" variant="primary" loading={busy} disabled={busy || expired || !canLaunch}>Queue {preview.servers.length} server{preview.servers.length === 1 ? '' : 's'} →</Button></div></div>
|
||||||
</form>
|
</form>
|
||||||
{:else}<p class="muted">Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
{:else}<p class="muted">Your fleet already meets or exceeds every target in this blueprint. No servers will be created or removed.</p><div class="actions"><Button onclick={() => open = false}>Done</Button></div>{/if}
|
||||||
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted 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.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
<details class="recipes"><summary>Setup and launch requirements</summary><p class="muted 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.</p>{#each preview.entries as entry}<div class="recipe"><LaunchRecipe recipe={entry.recipe} /></div>{/each}</details>
|
||||||
@@ -87,10 +109,15 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
input { scroll-margin-block: 32px 190px; }
|
||||||
|
.launch-footer { position: sticky; bottom: -28px; background: var(--surface); z-index: 2; padding: 16px 0 20px; border-top: 1px solid var(--border-strong); box-shadow: 0 -12px 16px var(--surface); }
|
||||||
|
.map-group { font-size: 12px; color: var(--accent-primary-hover); }
|
||||||
|
.coverage { margin-top: 16px; font-size: 14px; }.coverage summary { cursor: pointer; }.coverage-map { padding-top: 14px; }.coverage ul { list-style: none; padding: 0; }.coverage li { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }.coverage a { color: var(--accent-primary-hover); }.coverage li span { color: var(--text-secondary); }
|
||||||
|
@media (max-width: 600px) { .launch-footer { bottom: -20px; } }
|
||||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.composer { display: grid; gap: 20px; padding: 28px; }
|
||||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }h2, h3, p { margin: 0; }h2 { font-size: 25px; overflow-wrap: anywhere; }h3 { font-size: 16px; }.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; }.close { border: 0; background: none; color: var(--text-secondary); font-size: 26px; cursor: pointer; }.muted, .caption { font-size: 13px; color: var(--text-secondary); line-height: 1.6; }.caption { font-size: var(--text-xs); }.error { color: var(--state-danger); font-size: 13px; line-height: 1.6; }
|
||||||
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
.lineup { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; }.lineup > strong { font-size: 18px; }.quantity { color: var(--accent-primary-hover); font-weight: 600; }.lineup small { color: var(--text-secondary); }.lineup table { width: 100%; margin: 16px 0; border-collapse: collapse; font-size: 12px; table-layout: fixed; }.lineup th, .lineup td { text-align: center; padding: 10px 4px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }.lineup th:first-child { text-align: left; width: 34%; }.lineup thead { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
form { display: grid; gap: 20px; }.servers { margin: 0; padding: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); overflow: hidden; }.server-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) 220px; align-items: center; gap: 16px; padding: 20px 16px; }.server-row + .server-row { border-top: 1px solid var(--border-subtle); }.order { font-family: var(--font-mono); color: var(--text-tertiary); font-size: 12px; align-self: start; padding-top: 3px; }.server-identity { display: grid; gap: 8px; min-width: 0; }.server-row strong { font-size: 14px; overflow-wrap: anywhere; }.server-id { font-family: var(--font-mono); font-size: 11px; line-height: 1.6; color: var(--text-secondary); overflow-wrap: anywhere; }label { display: grid; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }input { width: 100%; box-sizing: border-box; border: 1px solid var(--border-control); border-radius: 7px; background: var(--surface-sunken); color: var(--text-primary); padding: 10px; font-size: 13px; }.actions { justify-content: flex-end; }.text-button { border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font-size: 12px; }.recipes { border-top: 1px solid var(--border-subtle); padding-top: 20px; }.recipes summary { cursor: pointer; font-size: 13px; }.recipe { padding: 20px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||||
.pacing-note { display: flex; align-items: flex-start; gap: 14px; background: color-mix(in srgb, var(--accent-primary) 8%, transparent); border-left: 2px solid var(--accent-primary); padding: 16px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }.pacing-note svg { flex: 0 0 24px; width: 24px; height: 24px; color: var(--accent-primary-hover); }.pacing-note strong { font-size: 14px; }.pacing-note p { margin-top: 6px; color: var(--text-secondary); font-size: 12px; line-height: 1.6; }.launch-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; }.requirements { margin-top: 16px; }button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
.pacing-note { display: flex; align-items: flex-start; gap: 14px; background: color-mix(in srgb, var(--accent-primary) 8%, transparent); border-left: 2px solid var(--accent-primary); padding: 16px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }.pacing-note svg { flex: 0 0 24px; width: 24px; height: 24px; color: var(--accent-primary-hover); }.pacing-note strong { font-size: 14px; }.pacing-note p { margin-top: 6px; color: var(--text-secondary); font-size: 12px; line-height: 1.6; }.launch-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; }.requirements { margin-top: 16px; }button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||||
@media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.server-row label { grid-column: 2; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
|
@media (max-width: 600px) { .composer { padding: 20px; }.lineup { padding: 16px; }.server-row { grid-template-columns: 24px minmax(0, 1fr); gap: 12px; padding: 16px; }.server-row label { grid-column: 2; }.launch-footer { align-items: stretch; flex-direction: column; gap: 16px; }.actions { justify-content: space-between; }h2 { font-size: 22px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
@@ -29,6 +30,11 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let wasOpen = false;
|
let wasOpen = false;
|
||||||
let session = 0;
|
let session = 0;
|
||||||
|
let keyNotice = $state('');
|
||||||
|
let now = $state(Date.now());
|
||||||
|
let heading: HTMLHeadingElement;
|
||||||
|
const expired = $derived(!!preview && new Date(preview.expiresAt).getTime() <= now);
|
||||||
|
onMount(() => { const timer = setInterval(() => { now = Date.now(); }, 10_000); return () => clearInterval(timer); });
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!dialog) return;
|
if (!dialog) return;
|
||||||
@@ -38,7 +44,7 @@
|
|||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
const current = ++session;
|
const current = ++session;
|
||||||
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false;
|
preview = null; plutoKey = ''; error = ''; loaded = false; busy = false; keyNotice = '';
|
||||||
blueprintName = ''; description = ''; savedName = ''; saving = false;
|
blueprintName = ''; description = ''; savedName = ''; saving = false;
|
||||||
blueprintId = initialBlueprintId;
|
blueprintId = initialBlueprintId;
|
||||||
game = initialGame;
|
game = initialGame;
|
||||||
@@ -63,19 +69,24 @@
|
|||||||
open = false; collectionOpen = true; return;
|
open = false; collectionOpen = true; return;
|
||||||
}
|
}
|
||||||
const current = session;
|
const current = session;
|
||||||
busy = true; error = ''; plutoKey = ''; preview = null; savedName = '';
|
const previousName = preview?.name;
|
||||||
|
busy = true; error = ''; savedName = ''; keyNotice = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
const res = await fetch('/api/deployments/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
|
body: JSON.stringify(sourceDeploymentId ? { deploymentId: sourceDeploymentId } : blueprintId ? { blueprintId } : { game, mapSlug }) });
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
if (!res.ok) throw new Error(body.error ?? 'Could not prepare launch preview.');
|
||||||
if (current === session) preview = body;
|
if (current === session) {
|
||||||
|
if (previousName && previousName !== body.name) { plutoKey = ''; keyNotice = 'The available server ID changed. Enter the key for the new ID below; the previous key was not transferred.'; }
|
||||||
|
preview = body; now = Date.now();
|
||||||
|
await tick(); heading?.focus();
|
||||||
|
}
|
||||||
} catch (err) { if (current === session) error = (err as Error).message; }
|
} catch (err) { if (current === session) error = (err as Error).message; }
|
||||||
finally { if (current === session) busy = false; }
|
finally { if (current === session) busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function launch() {
|
async function launch() {
|
||||||
if (!preview || !preview.ready || busy) return;
|
if (!preview || !preview.ready || expired || busy) return;
|
||||||
busy = true; error = '';
|
busy = true; error = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
const res = await fetch('/api/servers', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -106,22 +117,25 @@
|
|||||||
|
|
||||||
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
|
<dialog bind:this={dialog} aria-label="Create server" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
<header><div><span class="eyebrow">{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}</span><h2>{preview ? 'Review your next lobby' : 'Create your next server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header>
|
<header><div><h2 bind:this={heading} tabindex="-1">{preview ? 'Review server launch' : 'Create server'}</h2></div><button class="close" disabled={busy || saving} onclick={() => open = false} aria-label="Close deployment composer">×</button></header>
|
||||||
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
|
<ol class="steps" aria-label="Launch steps"><li class:active={!preview} aria-current={!preview ? 'step' : undefined}>01 <span>Choose a starting point</span></li><li class:active={!!preview} aria-current={preview ? 'step' : undefined}>02 <span>Review & launch</span></li></ol>
|
||||||
{#if error}<div class="error" role="alert">{error}</div>{/if}
|
{#if error}<div class="error" role="alert">{error}</div>{/if}
|
||||||
{#if !loaded}
|
{#if !loaded}
|
||||||
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p>
|
<p class="muted">{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and blueprints…'}</p>
|
||||||
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
{#if error}<Button onclick={initialize}>Try again</Button>{/if}
|
||||||
{:else if preview}
|
{:else if preview}
|
||||||
|
{#if keyNotice}<p role="status" class="muted">{keyNotice}</p>{/if}
|
||||||
|
{#if expired}<p class="error" role="status">This preview expired. Refresh it to recheck capacity. Keys stay entered when the server ID is unchanged.</p>{/if}
|
||||||
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {preview.blueprintName}</p>{/if}</section>
|
<section class="recipe-panel"><LaunchRecipe recipe={preview.recipe} />{#if preview.blueprintName}<p class="muted">From blueprint: {preview.blueprintName}</p>{/if}</section>
|
||||||
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
|
<section class="preflight"><div class="section-title"><h3>Before you launch</h3><button class="text-button" disabled={busy || saving} onclick={review}>Refresh preview</button></div>
|
||||||
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
|
<ul>{#each preview.checks as check}<li class:blocked={check.state === 'blocked'}><span class="check" aria-label={check.state}>{check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}</span><div><strong>{check.label}</strong><p>{check.detail}</p></div></li>{/each}</ul>
|
||||||
</section>
|
</section>
|
||||||
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
<form onsubmit={event => { event.preventDefault(); void launch(); }}>
|
||||||
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
|
<div class="identity"><span class="eyebrow">Your new server</span><strong>{preview.name ?? 'Waiting for an available lobby'}</strong><p>{preview.port ? `Port ${preview.port} · ` : ''}Fresh identity, with its own server key.</p></div>
|
||||||
<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key ready — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
|
<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder={preview.hasSavedKey ? 'Saved key available — leave blank to reuse' : 'Paste the key for this server ID'} /></label>
|
||||||
<p class="muted">{preview.hasSavedKey ? 'The key saved for this exact lobby will be reused. Enter a replacement to update it.' : 'Your key is saved for this exact lobby when you launch, including if provisioning fails.'} <a href="/server-keys" onclick={() => open = false}>Manage keys</a></p>
|
<p class="muted">{preview.hasSavedKey ? 'A key is stored for this exact server ID; its validity is checked when the game starts. Enter a replacement here to update it at launch.' : 'Add the key here. It is saved for this exact server ID when you launch, including if provisioning fails.'} <a href={`/server-keys?server=${encodeURIComponent(preview.name ?? '')}`} target="_blank" rel="noopener">Manage saved keys in a new tab</a>; your launch stays open here.</p>
|
||||||
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
<div class="launch-actions">{#if !sourceDeploymentId}<Button variant="ghost" disabled={busy || saving} onclick={() => { preview = null; plutoKey = ''; error = ''; }}>Back</Button>{/if}<Button type="submit" variant="primary" loading={busy} disabled={busy || saving || expired || !preview.ready || (!preview.hasSavedKey && !plutoKey.trim())}>Launch server</Button></div>
|
||||||
|
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
|
||||||
<p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
|
<p class="caption">Preview valid until {new Date(preview.expiresAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}. Lobby and port availability are checked again at launch.</p>
|
||||||
</form>
|
</form>
|
||||||
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
|
<details class="save"><summary>Make this a reusable blueprint</summary><p class="muted">Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.</p>
|
||||||
@@ -152,7 +166,7 @@
|
|||||||
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
|
h2 { font-size: 25px; letter-spacing: -.03em; margin: 0; } h3 { font-size: 15px; margin: 0; }
|
||||||
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
|
.close { background: none; color: var(--text-secondary); border: 0; font-size: 27px; cursor: pointer; }
|
||||||
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
|
.steps { list-style: none; padding: 0 0 20px; margin: 0; display: flex; gap: 24px; border-bottom: 1px solid var(--border-subtle); font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); }.steps span { font-family: var(--font-sans); margin-left: 6px; }.steps .active { color: var(--accent-primary-hover); }
|
||||||
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
form { display: grid; gap: 16px; } label { display: grid; gap: 8px; font-size: 13px; } input, select { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-md); padding: 12px; font: inherit; } input:focus, select:focus { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||||
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); }
|
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } p { font-size: 13px; line-height: 1.6; margin: 0; } .muted, .caption { color: var(--text-secondary); } .caption { font-size: var(--text-xs); }
|
||||||
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
|
.recipe-panel, .promise { background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; display: grid; gap: 12px; }.promise strong { font-size: 14px; } .promise p { color: var(--text-secondary); }
|
||||||
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
|
.preflight { display: grid; gap: 16px; } .preflight ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 14px; } .preflight li { display: flex; gap: 12px; align-items: flex-start; }.preflight strong { font-size: 13px; font-weight: 550; }.preflight p { color: var(--text-secondary); font-size: 12px; }.check { display: grid; place-items: center; flex: 0 0 22px; height: 22px; border: 1px solid var(--border-strong); border-radius: 50%; font-size: 12px; color: var(--accent-primary-hover); }.blocked .check { color: var(--state-danger); border-color: var(--state-danger); }
|
||||||
|
|||||||
@@ -30,18 +30,18 @@
|
|||||||
<dialog bind:this={dialog} aria-labelledby="destroy-title" onclose={() => open = false}
|
<dialog bind:this={dialog} aria-labelledby="destroy-title" onclose={() => open = false}
|
||||||
oncancel={(event) => { if (submitting) event.preventDefault(); }}>
|
oncancel={(event) => { if (submitting) event.preventDefault(); }}>
|
||||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||||
<h2 id="destroy-title">Destroy container?</h2>
|
<h2 id="destroy-title">Queue server removal?</h2>
|
||||||
<p class="name">{name}</p>
|
<p class="name">{name}</p>
|
||||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and rollout details. Deployment history and the saved server key are retained.</p>
|
<p>This stops the container, deletes its Komodo deployment, and removes its server files and rollout details. Deployment history and the saved server key are retained.</p>
|
||||||
<div class="notice">
|
<div class="notice">
|
||||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||||
Destruction waits until a live check confirms the server is empty. You can follow its progress on the deployment page.
|
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the deployment page.
|
||||||
</div>
|
</div>
|
||||||
<p class="irreversible">Once destruction begins, it cannot be undone.</p>
|
<p class="irreversible">You can cancel while waiting. Once removal begins, it cannot be undone.</p>
|
||||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||||
<footer>
|
<footer>
|
||||||
<Button variant="ghost" disabled={submitting} onclick={() => open = false}>Cancel</Button>
|
<Button variant="ghost" disabled={submitting} onclick={() => open = false}>Cancel</Button>
|
||||||
<Button variant="danger" type="submit" loading={submitting}>Destroy container</Button>
|
<Button variant="danger" type="submit" loading={submitting}>Queue server removal</Button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -47,9 +47,10 @@
|
|||||||
|
|
||||||
<dialog bind:this={dialog} aria-label="Destroy fleet" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
<dialog bind:this={dialog} aria-label="Destroy fleet" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<header><span class="eyebrow">House Party Protocol</span><h2>Destroy fleet</h2></header>
|
<header><h2>Queue fleet removal</h2></header>
|
||||||
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
|
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
|
||||||
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p>
|
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and blueprints are kept.</p>
|
||||||
|
<p>In Active deployments, cancel individual waiting requests or all waiting removals. Once a server begins removal, that removal cannot be cancelled or undone.</p>
|
||||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||||
{#if servers}
|
{#if servers}
|
||||||
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'}</strong>
|
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'}</strong>
|
||||||
@@ -67,7 +68,6 @@
|
|||||||
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
||||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
||||||
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
|
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
|
||||||
.eyebrow { display: block; color: var(--state-danger); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
|
|
||||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
|
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
|
||||||
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
|
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
|
||||||
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
|
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick, untrack } from 'svelte';
|
import { tick, untrack, onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { toast } from '$ui/toast.js';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
import Tabs from './primitives/Tabs.svelte';
|
import Tabs from './primitives/Tabs.svelte';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
@@ -30,14 +33,15 @@
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let active = $state<string>('');
|
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
|
||||||
let data = $state<Record<string, LogResponse | null>>({});
|
let data = $state<Record<string, LogResponse | null>>({});
|
||||||
|
|
||||||
// Default to the first tab once props are populated; don't overwrite
|
function chooseLog(key: string) {
|
||||||
// the user's later selection.
|
const url = new URL($page.url); url.searchParams.set('log', key);
|
||||||
$effect(() => {
|
void goto(url, { noScroll: true, keepFocus: true, replaceState: true });
|
||||||
if (!tabs.some(tab => tab.key === active)) active = tabs[0]?.key ?? '';
|
}
|
||||||
});
|
let query = $state('');
|
||||||
|
onMount(() => { query = $page.url.searchParams.get('logQuery') ?? ''; });
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let autoRefresh = $state(true);
|
let autoRefresh = $state(true);
|
||||||
let refreshError = $state('');
|
let refreshError = $state('');
|
||||||
@@ -62,7 +66,7 @@
|
|||||||
} finally {
|
} finally {
|
||||||
pending.delete(requestKey);
|
pending.delete(requestKey);
|
||||||
if (server === currentServer && key === active) loading = false;
|
if (server === currentServer && key === active) loading = false;
|
||||||
if (stickToBottom && server === currentServer && key === active) {
|
if (stickToBottom && !query && server === currentServer && key === active) {
|
||||||
await tick();
|
await tick();
|
||||||
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleAuto() { autoRefresh = !autoRefresh; }
|
function toggleAuto() { autoRefresh = !autoRefresh; }
|
||||||
|
async function jumpToLatest() {
|
||||||
|
query = ''; stickToBottom = true;
|
||||||
|
await tick(); if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||||
|
}
|
||||||
|
async function copy(value: string, message: string) {
|
||||||
|
try { await navigator.clipboard.writeText(value); toast(message); }
|
||||||
|
catch { toast('Could not copy. Select the log text to copy manually.', { variant: 'danger' }); }
|
||||||
|
}
|
||||||
|
function copyLink() {
|
||||||
|
const url = new URL(window.location.href); url.searchParams.set('tab', 'logs'); url.searchParams.set('log', active);
|
||||||
|
if (query) url.searchParams.set('logQuery', query); else url.searchParams.delete('logQuery');
|
||||||
|
void copy(url.href, 'Log link copied.');
|
||||||
|
}
|
||||||
|
function download() {
|
||||||
|
const blob = new Blob([(current?.lines ?? []).join('\n')], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a'); link.href = url; link.download = `${server}-${active}.log`; link.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
function highlight(line: string) {
|
||||||
|
if (!query) return [{ text: line, match: false }];
|
||||||
|
const parts: { text: string; match: boolean }[] = []; let start = 0; let index: number;
|
||||||
|
while ((index = line.toLowerCase().indexOf(query.toLowerCase(), start)) !== -1) {
|
||||||
|
parts.push({ text: line.slice(start, index), match: false }, { text: line.slice(index, index + query.length), match: true }); start = index + query.length;
|
||||||
|
}
|
||||||
|
parts.push({ text: line.slice(start), match: false }); return parts;
|
||||||
|
}
|
||||||
$effect(() => { server; data = {}; refreshError = ''; });
|
$effect(() => { server; data = {}; refreshError = ''; });
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
server;
|
server;
|
||||||
@@ -109,24 +140,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let current = $derived(data[active] ?? null);
|
let current = $derived(data[active] ?? null);
|
||||||
|
const visibleLines = $derived((current?.lines ?? []).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="log-panel">
|
<div class="log-panel">
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} bind:value={active} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
||||||
<div class="right">
|
<div class="right">
|
||||||
{#if current?.mtime}
|
{#if current?.mtime}
|
||||||
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
|
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<label class="auto">
|
<label class="auto">
|
||||||
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
||||||
auto {pollMs / 1000}s
|
Auto-refresh · {pollMs / 1000}s
|
||||||
</label>
|
</label>
|
||||||
<button class="refresh" onclick={() => fetchLog(active)} disabled={loading}>
|
<button class="refresh" onclick={() => fetchLog(active)} disabled={loading}>
|
||||||
{loading ? '…' : 'refresh'}
|
{loading ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><div class="log-actions"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></div>
|
||||||
|
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
|
||||||
|
|
||||||
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
||||||
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if}
|
{#if refreshError}<div class="ui-error" role="alert">{refreshError} {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => fetchLog(active)} disabled={loading}>Retry</Button></div>{/if}
|
||||||
@@ -142,7 +176,9 @@
|
|||||||
{:else if current && (!current.lines || current.lines.length === 0)}
|
{:else if current && (!current.lines || current.lines.length === 0)}
|
||||||
<div class="empty">log is empty</div>
|
<div class="empty">log is empty</div>
|
||||||
{:else if current?.lines}
|
{:else if current?.lines}
|
||||||
<pre class="body" bind:this={bodyEl} onscroll={onScroll}>{current.lines.join('\n')}</pre>
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The log is keyboard-scrollable.) -->
|
||||||
|
<pre class="body" bind:this={bodyEl} onscroll={onScroll} tabindex="0" aria-label="{active} log contents">{#each visibleLines as line}{#each highlight(line) as part}{#if part.match}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}{'\n'}{/each}</pre>
|
||||||
|
{#if query && !visibleLines.length}<p class="empty">No matches in the loaded tail. Try another search or download the tail.</p>{/if}
|
||||||
{#if current.truncated}
|
{#if current.truncated}
|
||||||
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
|
<div class="foot">showing last {current.lines.length} lines (file is larger)</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -153,6 +189,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.log-tools, .log-actions, .follow-state { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.log-tools { align-items: end; }
|
||||||
|
.log-search { display: grid; gap: 6px; flex: 1; min-width: 180px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
.log-search input { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); border-radius: var(--radius-sm); padding: 9px 12px; font: inherit; }
|
||||||
|
.follow-state { justify-content: space-between; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
mark { background: var(--state-warning); color: var(--surface-sunken); }
|
||||||
.log-panel {
|
.log-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Dialog from './primitives/Dialog.svelte';
|
||||||
|
import Button from './primitives/Button.svelte';
|
||||||
|
import { notificationHistory, markNotificationsRead, clearNotifications } from '$ui/toast.js';
|
||||||
|
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||||
|
$effect(() => { if (open) markNotificationsRead(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open title="Recent notifications" description="Recent action results and completed lobby watches in this browser tab. Watches run while the app is open.">
|
||||||
|
{#if $notificationHistory.length}
|
||||||
|
<ol>{#each $notificationHistory as item (item.id)}<li class:attention={item.variant === 'danger' || item.variant === 'warning'}><p>{item.message}</p><time datetime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>{#if item.href}<a href={item.href} onclick={() => open = false}>View details →</a>{/if}</li>{/each}</ol>
|
||||||
|
{:else}<p class="empty">No recent notifications. Completed lobby watches and action results will appear here.</p>{/if}
|
||||||
|
{#snippet actions()}<Button disabled={!$notificationHistory.length} onclick={clearNotifications}>Clear history</Button><Button onclick={() => open = false}>Close</Button>{/snippet}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
|
||||||
|
li { border: 1px solid var(--border-strong); padding: 12px; border-radius: var(--radius-md); }
|
||||||
|
li.attention { border-color: var(--state-warning); }
|
||||||
|
p { margin: 0 0 8px; font-size: var(--text-sm); }
|
||||||
|
time { display: block; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
|
a { display: inline-block; margin-top: 8px; font-size: var(--text-sm); }
|
||||||
|
.empty { color: var(--text-secondary); }
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
|
let { steps, events, active = false }: { steps: OperationStep[]; events: ActivityEvent[]; active?: boolean } = $props();
|
||||||
let limit = $state(25);
|
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));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -17,23 +22,37 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div class="top"><h2>Activity</h2>{#if active}<span class="muted">Updates automatically</span>{/if}</div>
|
{#if latest}<p class="latest"><strong>Latest update</strong> {latest.message}<time datetime={latest.created_at}>{new Date(latest.created_at).toLocaleString()}</time></p>{/if}
|
||||||
|
<details>
|
||||||
|
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
|
||||||
{#if events.length}
|
{#if events.length}
|
||||||
{#if events.length > limit}<Button size="sm" onclick={() => limit += 25}>Show earlier activity ({events.length - limit})</Button>{/if}
|
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
|
||||||
<ol class="timeline">
|
<ol class="timeline">
|
||||||
{#each events.slice(-limit) as event (event.id)}
|
{#each visible as event (event.id)}
|
||||||
<li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
|
<li><time datetime={event.created_at}>{new Date(event.created_at).toLocaleString()}</time><p>{event.message}</p></li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
|
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
|
||||||
|
{#if matching.length > limit}<Button size="sm" onclick={() => limit += 25}>Show more activity ({matching.length - limit})</Button>{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown on this page.</p>
|
<p class="muted">This operation predates detailed activity tracking. Its recorded status and timestamps are shown on this page.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
section { display: grid; gap: 16px; }
|
section { display: grid; gap: 16px; }
|
||||||
h2 { font-size: 18px; margin: 0; }
|
h2 { font-size: 18px; margin: 0; }
|
||||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
details { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||||
|
summary { cursor: pointer; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||||
|
details[open] summary { margin-bottom: 16px; }
|
||||||
|
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||||
|
.latest strong { display: block; margin-bottom: 4px; }
|
||||||
|
.latest time { display: block; margin-top: 4px; }
|
||||||
|
.tools { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
|
||||||
|
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
label:first-child { flex: 1; min-width: 180px; }
|
||||||
|
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
|
||||||
time, .muted { color: var(--text-secondary); font-size: 13px; }
|
time, .muted { color: var(--text-secondary); font-size: 13px; }
|
||||||
p { margin: 0; line-height: 1.6; }
|
p { margin: 0; line-height: 1.6; }
|
||||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
|
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
|
||||||
import Card from './primitives/Card.svelte';
|
import Card from './primitives/Card.svelte';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
import Badge from './primitives/Badge.svelte';
|
import Badge from './primitives/Badge.svelte';
|
||||||
|
import { toast } from '$ui/toast.js';
|
||||||
|
|
||||||
let { pluginId, targetPath, disabled = false, onimport }: {
|
let { pluginId, targetPath, disabled = false, busy = $bindable(false), onimport }: {
|
||||||
pluginId?: number; targetPath?: string; disabled?: boolean; onimport?: () => void | Promise<void>;
|
pluginId?: number; targetPath?: string; disabled?: boolean; busy?: boolean; onimport?: (result: { pluginId: number; betaUrl: string; alreadyImported: boolean }) => void | Promise<void>;
|
||||||
} = $props();
|
} = $props();
|
||||||
let url = $state('');
|
let url = $state('');
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
@@ -15,10 +16,11 @@
|
|||||||
let release = $state<GithubPluginRelease | null>(null);
|
let release = $state<GithubPluginRelease | null>(null);
|
||||||
let assetId = $state('');
|
let assetId = $state('');
|
||||||
let update = $state<PluginUpdate | null>(null);
|
let update = $state<PluginUpdate | null>(null);
|
||||||
let busy = $state(false);
|
|
||||||
let checking = $state(false);
|
let checking = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
|
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
|
||||||
|
let active = true;
|
||||||
|
onDestroy(() => { active = false; });
|
||||||
|
|
||||||
function showRelease(value: GithubPluginRelease) {
|
function showRelease(value: GithubPluginRelease) {
|
||||||
release = value;
|
release = value;
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
|
|
||||||
async function importRelease(startBeta: boolean) {
|
async function importRelease(startBeta: boolean) {
|
||||||
if (!release || !assetId || busy || disabled) return;
|
if (!release || !assetId || busy || disabled) return;
|
||||||
|
const startPath = window.location.pathname;
|
||||||
busy = true; error = ''; success = null;
|
busy = true; error = ''; success = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/plugins/github/import', {
|
const response = await fetch('/api/plugins/github/import', {
|
||||||
@@ -67,11 +70,12 @@
|
|||||||
});
|
});
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
|
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
|
||||||
|
if (!active) { toast('Plugin imported. Review its version when ready.', { variant: 'success', href: `/plugins/${body.pluginId}` }); return; }
|
||||||
success = body;
|
success = body;
|
||||||
await onimport?.();
|
await onimport?.(body);
|
||||||
if (startBeta) await goto(body.betaUrl);
|
if (startBeta && window.location.pathname === startPath) await goto(body.betaUrl);
|
||||||
else if (pluginId) await checkUpdates();
|
else if (pluginId) await checkUpdates();
|
||||||
} catch (e) { error = (e as Error).message; }
|
} catch (e) { error = (e as Error).message; if (!active) toast(`Plugin import failed: ${error}`, { variant: 'danger', href: '/plugins' }); }
|
||||||
finally { busy = false; }
|
finally { busy = false; }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -172,4 +176,5 @@
|
|||||||
.success { display: block; padding: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
.success { display: block; padding: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||||
.success strong, .checksum { display: block; margin-bottom: var(--space-2); }
|
.success strong, .checksum { display: block; margin-bottom: var(--space-2); }
|
||||||
.checksum { overflow-wrap: anywhere; font: var(--text-xs) var(--font-mono); margin-top: var(--space-2); }
|
.checksum { overflow-wrap: anywhere; font: var(--text-xs) var(--font-mono); margin-top: var(--space-2); }
|
||||||
|
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
let message = $state('');
|
let message = $state('');
|
||||||
let preferredTarget = $state<number | null>(null);
|
let preferredTarget = $state<number | null>(null);
|
||||||
let nextKey = 0;
|
let nextKey = 0;
|
||||||
|
let activeFile = $state('');
|
||||||
|
const checkedCount = $derived(queue.filter(q => q.inspected).length);
|
||||||
const pending = $derived(queue.filter(q => !q.result));
|
const pending = $derived(queue.filter(q => !q.result));
|
||||||
const inspecting = $derived(queue.some(q => q.inspecting));
|
const inspecting = $derived(queue.some(q => q.inspecting));
|
||||||
const hasNew = $derived(pending.some(q => q.target === 'new'));
|
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());
|
return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase());
|
||||||
}
|
}
|
||||||
export async function addFiles(files: File[]) {
|
export async function addFiles(files: File[]) {
|
||||||
if (busy || !files.length) return;
|
if (busy || inspecting || !files.length) return;
|
||||||
if (!dialog.open) open();
|
if (!dialog.open) open();
|
||||||
for (const file of files) {
|
const items = files.map(file => {
|
||||||
const matches = matchingScripts(file);
|
const matches = matchingScripts(file);
|
||||||
const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
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 };
|
name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null };
|
||||||
queue = [...queue, item];
|
return item;
|
||||||
// Work with the reactive proxy, rather than the original object.
|
});
|
||||||
await inspect(queue.find(q => q.key === item.key)!);
|
queue = [...queue, ...items];
|
||||||
}
|
for (const item of items) await inspect(queue.find(q => q.key === item.key)!);
|
||||||
preferredTarget = null;
|
preferredTarget = null;
|
||||||
}
|
}
|
||||||
async function inspect(item: Upload) {
|
async function inspect(item: Upload) {
|
||||||
|
activeFile = item.file.name;
|
||||||
item.inspecting = true; item.error = '';
|
item.inspecting = true; item.error = '';
|
||||||
try {
|
try {
|
||||||
const form = new FormData(); form.append('content', item.file);
|
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.');
|
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
|
||||||
item.duplicates = body.duplicates; item.inspected = true;
|
item.duplicates = body.duplicates; item.inspected = true;
|
||||||
} catch (e) { item.error = (e as Error).message; }
|
} 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 duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
|
||||||
function validation(item: Upload): string {
|
function validation(item: Upload): string {
|
||||||
@@ -99,6 +102,7 @@
|
|||||||
busy = true; message = '';
|
busy = true; message = '';
|
||||||
try {
|
try {
|
||||||
for (const item of queue.filter(q => !q.result)) {
|
for (const item of queue.filter(q => !q.result)) {
|
||||||
|
activeFile = item.file.name;
|
||||||
item.error = '';
|
item.error = '';
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -126,7 +130,7 @@
|
|||||||
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
|
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
|
||||||
}
|
}
|
||||||
} catch (e) { message = (e as Error).message; }
|
} 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(); }
|
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
|
||||||
</script>
|
</script>
|
||||||
@@ -183,6 +187,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
||||||
<footer>
|
<footer>
|
||||||
|
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} need attention` : ''}</p>{/if}
|
||||||
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
||||||
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
||||||
</footer>
|
</footer>
|
||||||
@@ -205,6 +210,8 @@
|
|||||||
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
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; }
|
.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; }
|
.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; } }
|
@media (max-width: 640px) { .drawer { padding: 20px; } }
|
||||||
|
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
|
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
|
||||||
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
|
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
|
||||||
let showTiming = $derived(
|
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
|
// Shared-element transition to /servers/{name} — Chromium only, others
|
||||||
@@ -109,6 +109,8 @@
|
|||||||
<Badge variant="info" uppercase>Starting</Badge>
|
<Badge variant="info" uppercase>Starting</Badge>
|
||||||
{:else if health === 'offline'}
|
{:else if health === 'offline'}
|
||||||
<Badge variant="danger" uppercase>offline</Badge>
|
<Badge variant="danger" uppercase>offline</Badge>
|
||||||
|
{:else if health === 'stale'}
|
||||||
|
<Badge variant="warning">Stale</Badge>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="players" aria-live="polite">
|
<div class="players" aria-live="polite">
|
||||||
<Stat
|
<Stat
|
||||||
@@ -123,6 +125,11 @@
|
|||||||
|
|
||||||
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
|
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
|
||||||
|
|
||||||
|
{#if health === 'stale'}
|
||||||
|
<div class="stale-note"><strong>Players unknown</strong><span>Last known: {players ?? 'unknown'}{players !== null ? ' players' : ''}{lastSeenAt ? ` · ${ago(lastSeenAt, $uiNow)}` : ''}</span></div>
|
||||||
|
{#if topRoster.length}<div class="roster-heading">Last reported players</div>{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
|
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} since={gameStartedAt} />{/if}
|
||||||
|
|
||||||
{#if showTiming && !(compactEmpty && players === 0)}
|
{#if showTiming && !(compactEmpty && players === 0)}
|
||||||
@@ -185,6 +192,9 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.stale-note { display: grid; gap: 4px; margin-top: 14px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
.stale-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||||
|
.roster-heading { margin-top: 14px; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
/* Global tweaks scoped through :global() since Card is a child component. */
|
/* Global tweaks scoped through :global() since Card is a child component. */
|
||||||
:global(.card.restarting) {
|
:global(.card.restarting) {
|
||||||
outline: 1px solid rgba(255, 176, 32, 0.28);
|
outline: 1px solid rgba(255, 176, 32, 0.28);
|
||||||
|
|||||||
@@ -153,7 +153,7 @@
|
|||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
.is-loading .label,
|
.is-loading .label,
|
||||||
.is-loading .slot { visibility: hidden; }
|
.is-loading .slot { opacity: 0; }
|
||||||
.spinner {
|
.spinner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 14px; height: 14px;
|
width: 14px; height: 14px;
|
||||||
|
|||||||
@@ -18,14 +18,28 @@
|
|||||||
get messageId() { return error || hint ? `${id}-message` : undefined; },
|
get messageId() { return error || hint ? `${id}-message` : undefined; },
|
||||||
get invalid() { return !!error; }
|
get invalid() { return !!error; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function connectLabel(node: HTMLDivElement) {
|
||||||
|
const connect = () => {
|
||||||
|
const label = node.querySelector<HTMLLabelElement>('label.label');
|
||||||
|
const control = node.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('input:not([type="hidden"]), select, textarea');
|
||||||
|
if (!label || !control || forId) return;
|
||||||
|
if (!control.id) control.id = `${id}-control`;
|
||||||
|
label.htmlFor = control.id;
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
const observer = new MutationObserver(connect);
|
||||||
|
observer.observe(node, { childList: true, subtree: true });
|
||||||
|
return { destroy: () => observer.disconnect() };
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field" use:connectLabel>
|
||||||
{#if label}
|
{#if label}
|
||||||
<svelte:element this={forId ? 'label' : 'div'} class="label" id={`${id}-label`} for={forId}>
|
<label class="label" id={`${id}-label`} for={forId ?? `${id}-control`}>
|
||||||
{label}
|
{label}
|
||||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||||
</svelte:element>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="control" class:has-error={!!error}>
|
<div class="control" class:has-error={!!error}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-control);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
.input.mono { font-family: var(--font-mono); }
|
.input.mono { font-family: var(--font-mono); }
|
||||||
.input::placeholder { color: var(--text-quaternary); }
|
.input::placeholder { color: var(--text-quaternary); }
|
||||||
|
|
||||||
.input:hover { border-color: var(--border-strong); }
|
.input:hover { border-color: var(--border-control-hover); }
|
||||||
.input:focus {
|
.input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-primary);
|
border-color: var(--accent-primary);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Button from './Button.svelte';
|
import Button from './Button.svelte';
|
||||||
let { total, limit = $bindable(25), step = 25, label = 'items' }: { total: number; limit?: number; step?: number; label?: string } = $props();
|
let { total, limit = $bindable(25), step = 25, label = 'items', onchange }: { total: number; limit?: number; step?: number; label?: string; onchange?: (limit: number) => void } = $props();
|
||||||
</script>
|
</script>
|
||||||
{#if total > step}
|
{#if total > step}
|
||||||
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => limit += step}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
|
<div class="list-footer"><span role="status">Showing {Math.min(limit, total)} of {total} {label}</span>{#if limit < total}<Button size="sm" onclick={() => { limit += step; onchange?.(limit); }}>Show {Math.min(step, total - limit)} more</Button>{/if}</div>
|
||||||
{/if}
|
{/if}
|
||||||
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
|
<style>.list-footer { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding-block: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }</style>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
padding: 9px 32px 9px 12px;
|
padding: 9px 32px 9px 12px;
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-control);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
background-color var(--dur-fast) var(--ease-standard),
|
background-color var(--dur-fast) var(--ease-standard),
|
||||||
box-shadow 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 {
|
.select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-primary);
|
border-color: var(--accent-primary);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -18,6 +19,32 @@
|
|||||||
|
|
||||||
const uid = $props.id();
|
const uid = $props.id();
|
||||||
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
|
let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
|
||||||
|
let tablist = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Keep a deep-linked or keyboard-selected tab visible without scrolling the page.
|
||||||
|
$effect(() => {
|
||||||
|
const selected = value;
|
||||||
|
const node = tablist;
|
||||||
|
if (!node) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let observer: ResizeObserver | undefined;
|
||||||
|
void tick().then(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const tab = document.getElementById(`${idPrefix}-${selected}`);
|
||||||
|
if (!tab) return;
|
||||||
|
const reveal = () => {
|
||||||
|
const bounds = node.getBoundingClientRect();
|
||||||
|
const target = tab.getBoundingClientRect();
|
||||||
|
if (target.left < bounds.left + 4) node.scrollLeft -= bounds.left + 4 - target.left;
|
||||||
|
else if (target.right > bounds.right - 4) node.scrollLeft += target.right - bounds.right + 4;
|
||||||
|
};
|
||||||
|
reveal();
|
||||||
|
observer = new ResizeObserver(reveal);
|
||||||
|
observer.observe(node);
|
||||||
|
observer.observe(tab);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; observer?.disconnect(); };
|
||||||
|
});
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
function onKey(event: KeyboardEvent) {
|
||||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||||
@@ -38,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel}>
|
<div class="tabs s-{size}" role="tablist" aria-label={ariaLabel} bind:this={tablist}>
|
||||||
{#each tabs as t (t.id)}
|
{#each tabs as t (t.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-3) var(--space-3);
|
padding: var(--space-3) var(--space-3);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-control);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
}
|
}
|
||||||
.textarea.mono { font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); }
|
.textarea.mono { font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); }
|
||||||
.textarea::placeholder { color: var(--text-quaternary); }
|
.textarea::placeholder { color: var(--text-quaternary); }
|
||||||
.textarea:hover { border-color: var(--border-strong); }
|
.textarea:hover { border-color: var(--border-control-hover); }
|
||||||
.textarea:focus {
|
.textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-primary);
|
border-color: var(--accent-primary);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { replaceState } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
/** Keep library working views shareable and restore them on browser Back. */
|
||||||
|
export function saveLibraryContext(values: Record<string, string | number>, defaults: Record<string, string | number> = {}) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (value === '' || value === defaults[key]) url.searchParams.delete(key);
|
||||||
|
else url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
if (url.href !== window.location.href) replaceState(url, get(page).state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function libraryLimit(params: URLSearchParams): number {
|
||||||
|
return Math.max(25, Math.min(10000, Number.parseInt(params.get('limit') ?? '25', 10) || 25));
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ const states: Record<string, [string, StatusTone]> = {
|
|||||||
'awaiting-health': ['Verifying health', 'info'], 'awaiting-match': ['Waiting for a match', 'neutral'],
|
'awaiting-health': ['Verifying health', 'info'], 'awaiting-match': ['Waiting for a match', 'neutral'],
|
||||||
ready: ['Ready', 'online'], 'ready-promote': ['Ready to promote', 'online'],
|
ready: ['Ready', 'online'], 'ready-promote': ['Ready to promote', 'online'],
|
||||||
done: ['Completed', 'online'], passed: ['Test passed', 'online'], tested: ['Test passed', 'online'],
|
done: ['Completed', 'online'], passed: ['Test passed', 'online'], tested: ['Test passed', 'online'],
|
||||||
promoted: ['Promoted', 'online'], 'rolled-back': ['Rolled back', 'neutral'],
|
promoted: ['Promoted', 'online'], 'rolled-back': ['Rolled back', 'neutral'], cancelled: ['Cancelled', 'neutral'],
|
||||||
failed: ['Failed', 'danger'], 'test-failed': ['Test failed', 'danger'], interrupted: ['Interrupted', 'danger'],
|
failed: ['Failed', 'danger'], 'test-failed': ['Test failed', 'danger'], interrupted: ['Interrupted', 'danger'],
|
||||||
warning: ['Completed with warnings', 'warning'], held: ['Held by beta', 'neutral'],
|
warning: ['Completed with warnings', 'warning'], held: ['Held by beta', 'neutral'],
|
||||||
manual: ['Manual action needed', 'warning'], paused: ['Paused', 'warning'], inconclusive: ['Missing evidence', 'warning'],
|
manual: ['Manual action needed', 'warning'], paused: ['Paused', 'warning'], inconclusive: ['Missing evidence', 'warning'],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
:where(input:not([type='checkbox']):not([type='radio']), select, textarea) {
|
:where(input:not([type='checkbox']):not([type='radio']), select, textarea) {
|
||||||
min-width: 0; max-width: 100%; min-height: 40px; padding: 10px 12px;
|
min-width: 0; max-width: 100%; min-height: 40px; padding: 10px 12px;
|
||||||
color: var(--text-primary); background: var(--surface-sunken);
|
color: var(--text-primary); background: var(--surface-sunken);
|
||||||
border: 1px solid var(--border-strong); border-radius: var(--radius-md);
|
border: 1px solid var(--border-control); border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
:where(input, textarea)::placeholder { color: var(--text-tertiary); opacity: 1; }
|
:where(input, textarea)::placeholder { color: var(--text-tertiary); opacity: 1; }
|
||||||
:where(button, summary) { cursor: pointer; }
|
:where(button, summary) { cursor: pointer; }
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||||
--border-strong: rgba(255, 255, 255, 0.12);
|
--border-strong: rgba(255, 255, 255, 0.12);
|
||||||
--border-focus: var(--blue-500);
|
--border-focus: var(--blue-500);
|
||||||
|
--border-control: #576176;
|
||||||
|
--border-control-hover: #78869f;
|
||||||
|
|
||||||
/* Accent — one color, one voice. */
|
/* Accent — one color, one voice. */
|
||||||
--accent-primary: var(--blue-500);
|
--accent-primary: var(--blue-500);
|
||||||
|
|||||||
@@ -18,6 +18,27 @@ export interface Toast {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const toasts = writable<Toast[]>([]);
|
export const toasts = writable<Toast[]>([]);
|
||||||
|
export interface Notice extends Toast { createdAt: string; read: boolean; }
|
||||||
|
export const notificationHistory = writable<Notice[]>([]);
|
||||||
|
const historyKey = 'electric-cherry:notifications';
|
||||||
|
|
||||||
|
export function startNotificationHistory(): () => void {
|
||||||
|
try {
|
||||||
|
const saved: unknown = JSON.parse(sessionStorage.getItem(historyKey) ?? '[]');
|
||||||
|
if (Array.isArray(saved)) {
|
||||||
|
const valid = saved.filter((item): item is Notice => item && typeof item.id === 'number' && Number.isSafeInteger(item.id)
|
||||||
|
&& typeof item.message === 'string' && ['info', 'success', 'warning', 'danger'].includes(item.variant)
|
||||||
|
&& typeof item.createdAt === 'string' && Number.isFinite(Date.parse(item.createdAt))
|
||||||
|
&& typeof item.read === 'boolean' && (!item.href || (typeof item.href === 'string' && item.href.startsWith('/') && !item.href.startsWith('//')))).slice(0, 50);
|
||||||
|
notificationHistory.set(valid);
|
||||||
|
nextId = Math.max(nextId, ...valid.map(item => item.id + 1));
|
||||||
|
}
|
||||||
|
} catch { /* Notifications still work when browser storage is unavailable. */ }
|
||||||
|
return notificationHistory.subscribe(items => { try { sessionStorage.setItem(historyKey, JSON.stringify(items)); } catch { /* Optional persistence. */ } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markNotificationsRead() { notificationHistory.update(items => items.map(item => ({ ...item, read: true }))); }
|
||||||
|
export function clearNotifications() { notificationHistory.set([]); }
|
||||||
|
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
||||||
@@ -33,6 +54,9 @@ export function toast(
|
|||||||
href: opts.href
|
href: opts.href
|
||||||
};
|
};
|
||||||
toasts.update((arr) => [...arr, t]);
|
toasts.update((arr) => [...arr, t]);
|
||||||
|
if (t.href || t.ttl === 0 || t.variant !== 'info') {
|
||||||
|
notificationHistory.update(items => [{ ...t, createdAt: new Date().toISOString(), read: false }, ...items].slice(0, 50));
|
||||||
|
}
|
||||||
if (t.ttl > 0) {
|
if (t.ttl > 0) {
|
||||||
setTimeout(() => dismiss(t.id), t.ttl);
|
setTimeout(() => dismiss(t.id), t.ttl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
import BrandMark from '$ui/components/BrandMark.svelte';
|
import BrandMark from '$ui/components/BrandMark.svelte';
|
||||||
import Toaster from '$ui/components/Toaster.svelte';
|
import Toaster from '$ui/components/Toaster.svelte';
|
||||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||||
|
import NotificationHistory from '$ui/components/NotificationHistory.svelte';
|
||||||
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
|
import { notificationHistory, startNotificationHistory } from '$ui/toast.js';
|
||||||
import { resolveConfirmation } from '$ui/confirm.js';
|
import { resolveConfirmation } from '$ui/confirm.js';
|
||||||
import '$ui/styles/components.css';
|
import '$ui/styles/components.css';
|
||||||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||||||
@@ -18,8 +21,17 @@
|
|||||||
interface Props { children: import('svelte').Snippet }
|
interface Props { children: import('svelte').Snippet }
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
|
let notificationsOpen = $state(false);
|
||||||
|
let helpOpen = $state(false);
|
||||||
|
const unread = $derived($notificationHistory.filter(item => !item.read).length);
|
||||||
let menuButton: HTMLButtonElement;
|
let menuButton: HTMLButtonElement;
|
||||||
|
|
||||||
|
function openUtility(panel: 'notifications' | 'help') {
|
||||||
|
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||||||
|
if (panel === 'notifications') notificationsOpen = true;
|
||||||
|
else helpOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
|
afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
|
||||||
|
|
||||||
function closeMenu(event: KeyboardEvent) {
|
function closeMenu(event: KeyboardEvent) {
|
||||||
@@ -30,6 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const stopNotifications = startNotificationHistory();
|
||||||
const stopPipeline = startPipelineStream();
|
const stopPipeline = startPipelineStream();
|
||||||
const stopServers = startServerStream();
|
const stopServers = startServerStream();
|
||||||
const stopFleetMonitor = startFleetMonitor();
|
const stopFleetMonitor = startFleetMonitor();
|
||||||
@@ -38,7 +51,7 @@
|
|||||||
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
||||||
const shortcuts: Record<string, string> = {
|
const shortcuts: Record<string, string> = {
|
||||||
f: '/', d: '/deployments', a: '/campaigns', b: '/betas',
|
f: '/', d: '/deployments', a: '/campaigns', b: '/betas',
|
||||||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard'
|
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings'
|
||||||
};
|
};
|
||||||
let armed = false;
|
let armed = false;
|
||||||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -47,6 +60,7 @@
|
|||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
|
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (e.key === '?') { e.preventDefault(); openUtility('help'); return; }
|
||||||
|
|
||||||
if (!armed && e.key === 'g') {
|
if (!armed && e.key === 'g') {
|
||||||
armed = true;
|
armed = true;
|
||||||
@@ -66,6 +80,7 @@
|
|||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => {
|
||||||
|
stopNotifications();
|
||||||
stopFleetMonitor();
|
stopFleetMonitor();
|
||||||
stopPipeline?.();
|
stopPipeline?.();
|
||||||
stopServers?.();
|
stopServers?.();
|
||||||
@@ -83,14 +98,14 @@
|
|||||||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
|
||||||
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
|
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
|
||||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
||||||
{ href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') },
|
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') || p.startsWith('/settings') },
|
||||||
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') }
|
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') }
|
||||||
];
|
];
|
||||||
const secondary = $derived(tabs[1].match($page.url.pathname)
|
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||||
: tabs[2].match($page.url.pathname)
|
: tabs[2].match($page.url.pathname)
|
||||||
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Rollouts' }]
|
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Rollouts' }]
|
||||||
: tabs[4].match($page.url.pathname) ? [{ href: '/server-keys', label: 'Server keys' }] : []);
|
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'System status' }, { href: '/server-keys', label: 'Server keys' }] : []);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={closeMenu} />
|
<svelte:window onkeydown={closeMenu} />
|
||||||
@@ -119,7 +134,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="strut" aria-hidden="true"></div>
|
<div class="nav-tools" class:expanded={menuOpen}><button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button></div>
|
||||||
</nav>
|
</nav>
|
||||||
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
{#if secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={$page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||||||
<main id="main-content" tabindex="-1">
|
<main id="main-content" tabindex="-1">
|
||||||
@@ -127,6 +142,10 @@
|
|||||||
</main>
|
</main>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Confirmation />
|
<Confirmation />
|
||||||
|
<NotificationHistory bind:open={notificationsOpen} />
|
||||||
|
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
|
||||||
|
<dl class="shortcuts">{#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]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -229,7 +248,13 @@
|
|||||||
view-transition-name: nav-active;
|
view-transition-name: nav-active;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strut { margin-left: auto; }
|
.nav-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||||
|
.utility { background: var(--surface-raised); color: var(--text-secondary); border: 1px solid var(--border-strong); padding: 8px 10px; border-radius: var(--radius-md); cursor: pointer; font: inherit; font-size: var(--text-xs); white-space: nowrap; }
|
||||||
|
.shortcuts { display: grid; gap: 12px; margin: 0; }
|
||||||
|
.shortcuts > div { display: flex; gap: 20px; align-items: center; }
|
||||||
|
.shortcuts dt { min-width: 55px; }
|
||||||
|
.shortcuts dd { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
kbd { font-family: var(--font-mono); padding: 3px 8px; border: 1px solid var(--border-strong); border-radius: 5px; }
|
||||||
.menu-toggle { display: none; }
|
.menu-toggle { display: none; }
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -242,7 +267,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The full navigation needs more room than a phone or portrait tablet. */
|
/* The full navigation needs more room than a phone or portrait tablet. */
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 1080px) {
|
||||||
nav {
|
nav {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -271,7 +296,8 @@
|
|||||||
.tabs { display: none; width: 100%; margin: 0; }
|
.tabs { display: none; width: 100%; margin: 0; }
|
||||||
.tabs.expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3) 0 4px; }
|
.tabs.expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3) 0 4px; }
|
||||||
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
|
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
|
||||||
.strut { display: none; }
|
.nav-tools { display: none; }
|
||||||
|
.nav-tools.expanded { display: flex; width: 100%; padding-top: 8px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); }
|
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); }
|
||||||
@@ -282,6 +308,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
|
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
|
||||||
|
:global(input), :global(select), :global(textarea), :global(button), :global(a) { scroll-margin-block: 90px 100px; }
|
||||||
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
|
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
|
||||||
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 300; padding: 12px 16px; background: var(--surface-raised); border-radius: var(--radius-md); }
|
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 300; padding: 12px 16px; background: var(--surface-raised); border-radius: var(--radius-md); }
|
||||||
.skip-link:focus { top: 12px; }
|
.skip-link:focus { top: 12px; }
|
||||||
|
|||||||
+40
-9
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { afterNavigate, replaceState } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import ServerCard from '$ui/components/ServerCard.svelte';
|
import ServerCard from '$ui/components/ServerCard.svelte';
|
||||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
@@ -7,7 +9,7 @@
|
|||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
|
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
|
||||||
import { fleet, fleetServers, fleetError, fleetRefreshing, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
|
import { fleet, fleetServers, fleetError, fleetRefreshing, fleetIntegrations, refreshFleet, lobbyWatches, followLobby, cancelWatch } from '$ui/stores/fleet.js';
|
||||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||||
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
|
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
|
||||||
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
|
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
let viewOptionsOpen = $state(false);
|
let viewOptionsOpen = $state(false);
|
||||||
let pinned = $state<string[]>([]);
|
let pinned = $state<string[]>([]);
|
||||||
let preferencesReady = $state(false);
|
let preferencesReady = $state(false);
|
||||||
|
let routerReady = $state(false);
|
||||||
let deployOpen = $state(false);
|
let deployOpen = $state(false);
|
||||||
let initialGame = $state<ZombieGame>('t5');
|
let initialGame = $state<ZombieGame>('t5');
|
||||||
let initialMapSlug = $state('');
|
let initialMapSlug = $state('');
|
||||||
@@ -33,6 +36,17 @@
|
|||||||
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
|
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function readViewUrl() {
|
||||||
|
const params = new URL(window.location.href).searchParams;
|
||||||
|
query = params.get('q') ?? '';
|
||||||
|
if (params.get('view') === 'cards' || params.get('view') === 'table') view = params.get('view') as typeof view;
|
||||||
|
if (['map', 'game', 'none'].includes(params.get('group') ?? '')) grouping = params.get('group') as typeof grouping;
|
||||||
|
if (['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '')) sort = params.get('sort') as FleetSort;
|
||||||
|
if (filters.some(item => item.id === params.get('filter'))) filter = params.get('filter') as FleetFilter;
|
||||||
|
if (params.has('game') && ['', 't5', 't6'].includes(params.get('game')!)) game = params.get('game')!;
|
||||||
|
}
|
||||||
|
afterNavigate(() => { routerReady = true; if (preferencesReady) readViewUrl(); });
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem('electric-cherry:fleet') ?? '{}');
|
const saved = JSON.parse(localStorage.getItem('electric-cherry:fleet') ?? '{}');
|
||||||
@@ -43,6 +57,7 @@
|
|||||||
if (['', 't5', 't6'].includes(saved.game)) game = saved.game;
|
if (['', 't5', 't6'].includes(saved.game)) game = saved.game;
|
||||||
if (Array.isArray(saved.pinned)) pinned = saved.pinned.filter((name: unknown) => typeof name === 'string');
|
if (Array.isArray(saved.pinned)) pinned = saved.pinned.filter((name: unknown) => typeof name === 'string');
|
||||||
} catch { /* Preferences also work when storage is disabled. */ }
|
} catch { /* Preferences also work when storage is disabled. */ }
|
||||||
|
readViewUrl();
|
||||||
preferencesReady = true;
|
preferencesReady = true;
|
||||||
void refreshFleet();
|
void refreshFleet();
|
||||||
});
|
});
|
||||||
@@ -51,7 +66,15 @@
|
|||||||
if (!preferencesReady) return;
|
if (!preferencesReady) return;
|
||||||
try { localStorage.setItem('electric-cherry:fleet', JSON.stringify({ view, grouping, sort, filter, game, pinned })); }
|
try { localStorage.setItem('electric-cherry:fleet', JSON.stringify({ view, grouping, sort, filter, game, pinned })); }
|
||||||
catch { /* The current view still works without persistent storage. */ }
|
catch { /* The current view still works without persistent storage. */ }
|
||||||
|
const values = { q: query, view, group: grouping, sort, filter, game };
|
||||||
|
if (!routerReady) return;
|
||||||
|
untrack(() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value);
|
||||||
|
if (url.href !== window.location.href) replaceState(url, $page.state);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
const snapshotAge = $derived.by(() => { $uiNow; return Math.max(0, Math.floor((Date.now() - $fleet.receivedAt) / 1000)); });
|
||||||
|
|
||||||
let servers = $derived($fleetServers.map(server => server.health === 'online' && !freshServer(server, $uiNow)
|
let servers = $derived($fleetServers.map(server => server.health === 'online' && !freshServer(server, $uiNow)
|
||||||
? { ...server, health: 'stale' as const } : server));
|
? { ...server, health: 'stale' as const } : server));
|
||||||
@@ -127,12 +150,16 @@
|
|||||||
{#if !$fleet.receivedAt}
|
{#if !$fleet.receivedAt}
|
||||||
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
|
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overview" aria-label="Fleet summary">
|
{#if servers.length}<div class="overview" aria-label="Fleet summary">
|
||||||
<span><strong>{healthy.length ? players : '—'}</strong> players online</span>
|
<span><strong>{healthy.length ? players : '—'}</strong> players online</span>
|
||||||
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
|
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
|
||||||
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
|
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
|
||||||
<a class="metric-link" href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
|
<a class="metric-link" class:no-rollouts={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
|
||||||
</div>
|
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
|
||||||
|
</div>{/if}
|
||||||
|
{#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
|
||||||
|
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if problems.length || attentionOperations.length || starting}
|
{#if problems.length || attentionOperations.length || starting}
|
||||||
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
|
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
|
||||||
@@ -174,7 +201,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if servers.length === 0}
|
{#if servers.length === 0}
|
||||||
<EmptyState title="Your first lobby starts here" description="Deploy a server or choose a saved blueprint to bring your fleet online." />
|
<EmptyState title="Your first server starts here" description="Check your connections and saved keys, then create a server or use a blueprint.">{#snippet actions()}<Button href="/settings">Check system setup</Button><Button href="/deployments?tab=blueprints" variant="ghost">Browse blueprints</Button>{/snippet}</EmptyState>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
@@ -199,9 +226,9 @@
|
|||||||
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if}
|
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if}
|
||||||
{#each groups as [key, group] (key)}
|
{#each groups as [key, group] (key)}
|
||||||
<section class="map-group" aria-label={group.title}>
|
<section class="map-group" aria-label={group.title}>
|
||||||
<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
|
{#if grouping !== 'none'}<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
|
||||||
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
|
{#if group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
|
||||||
</div>
|
</div>{/if}
|
||||||
{#if view === 'cards'}
|
{#if view === 'cards'}
|
||||||
<div class="cards">{#each group.servers as server (server.name)}
|
<div class="cards">{#each group.servers as server (server.name)}
|
||||||
<article class="server-tile" class:pinned={pinned.includes(server.name)}>
|
<article class="server-tile" class:pinned={pinned.includes(server.name)}>
|
||||||
@@ -252,6 +279,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.freshness { margin-left: auto; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
.setup-note { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
.setup-note strong { color: var(--text-primary); }
|
||||||
|
@media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-rollouts { display: none; } }
|
||||||
.page { display: flex; flex-direction: column; gap: var(--space-4); }
|
.page { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||||
button, input, select { font: inherit; }
|
button, input, select { font: inherit; }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
@@ -283,7 +314,7 @@
|
|||||||
.search-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
.search-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
|
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
|
||||||
.search { flex: 1; min-width: min(280px, 100%); }
|
.search { flex: 1; min-width: min(280px, 100%); }
|
||||||
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
|
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-control); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
|
||||||
input::placeholder { color: var(--text-secondary); }
|
input::placeholder { color: var(--text-secondary); }
|
||||||
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
|
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const test = storedPlan(b.id);
|
||||||
|
const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null;
|
||||||
return {
|
return {
|
||||||
...b,
|
...b,
|
||||||
testOutcome: storedPlan(b.id)?.outcome ?? null,
|
testOutcome: test?.outcome ?? null,
|
||||||
|
testReason: test?.reason ?? null,
|
||||||
|
deadlineAt: plan ? new Date(Date.parse(b.opened_at) + plan.timeoutHours * 3600000).toISOString() : null,
|
||||||
scripts,
|
scripts,
|
||||||
servers: listBetaServers(b.id)
|
servers: listBetaServers(b.id)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,5 +31,9 @@ export async function GET({ params }: { params: { id: string } }) {
|
|||||||
const servers = listBetaServers(id);
|
const servers = listBetaServers(id);
|
||||||
|
|
||||||
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id);
|
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id);
|
||||||
return json({ beta, scripts, servers, events, test: testView(id) });
|
const rollout = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
|
||||||
|
const promotion = rollout ? { ...rollout,
|
||||||
|
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state NOT IN ('done','failed','cancelled')").get(rollout.id) as { n: number }).n,
|
||||||
|
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state = 'failed'").get(rollout.id) as { n: number }).n } : null;
|
||||||
|
return json({ beta, scripts, servers, events, test: testView(id), promotion });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { promoteBeta, BetaError } from '$lib/server/fleet/beta.js';
|
import { promoteBeta, previewBetaPromotion, BetaError } from '$lib/server/fleet/beta.js';
|
||||||
|
|
||||||
function parseId(raw: string): number | null {
|
function parseId(raw: string): number | null {
|
||||||
const n = Number.parseInt(raw, 10);
|
const n = Number.parseInt(raw, 10);
|
||||||
return Number.isFinite(n) ? n : null;
|
return Number.isFinite(n) ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST({ params }: { params: { id: string } }) {
|
export async function GET({ params }: { params: { id: string } }) {
|
||||||
|
const id = parseId(params.id);
|
||||||
|
if (id === null) return json({ ok: false, error: 'Invalid beta ID.' }, { status: 400 });
|
||||||
|
try { return json({ preview: previewBetaPromotion(id) }, { headers: { 'Cache-Control': 'no-store' } }); }
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof BetaError) return json({ error: err.message }, { status: 409 });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||||
const id = parseId(params.id);
|
const id = parseId(params.id);
|
||||||
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
if (typeof body?.previewToken !== 'string') return json({ ok: false, error: 'Review promotion targets before confirming.' }, { status: 400 });
|
||||||
try {
|
try {
|
||||||
const result = await promoteBeta(id);
|
const result = await promoteBeta(id, body.previewToken);
|
||||||
return json({ ok: true, result });
|
return json({ ok: true, result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BetaError) {
|
if (err instanceof BetaError) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function POST({ request }: { request: Request }) {
|
|||||||
return json({ error: 'Server, config ID and text are required.' }, { status: 400 });
|
return json({ error: 'Server, config ID and text are required.' }, { status: 400 });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return json({ inspection: inspect(body.server, body) }, { headers: { 'Cache-Control': 'no-store' } });
|
return json({ inspection: inspect(body.server, body), promoted: inspect(body.server) }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return json({ error: (err as Error).message }, { status: 400 });
|
return json({ error: (err as Error).message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { cancelRemoval } from '$lib/server/fleet/deployments.js';
|
||||||
|
|
||||||
|
export function POST({ params }: { params: { id: string } }) {
|
||||||
|
if (!cancelRemoval(params.id)) return json({ error: 'Removal has already begun or this request is no longer waiting. Refresh to see its current state.' }, { status: 409 });
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { runDriftDeployment, scanFleetDrift } from '$lib/server/fleet/drift.js';
|
import { DriftPlanChanged, previewDrift, runDriftDeployment } from '$lib/server/fleet/drift.js';
|
||||||
import { kick } from '$lib/server/pipeline/worker.js';
|
import { kick } from '$lib/server/pipeline/worker.js';
|
||||||
|
|
||||||
// GET returns the current drift plan for the whole fleet without touching disk.
|
// GET returns the current drift plan for the whole fleet without touching disk.
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const entries = await scanFleetDrift();
|
const plan = await previewDrift();
|
||||||
|
const entries = plan.files.map(file => ({ ...file, size: file.afterSize ?? file.beforeSize ?? 0, sha256: file.afterSha256 ?? file.beforeSha256 ?? '' }));
|
||||||
const byServer = new Map<
|
const byServer = new Map<
|
||||||
string,
|
string,
|
||||||
Array<{ relPath: string; size: number; sha256: string; kind: string; driftKind: string }>
|
Array<{ relPath: string; size: number; sha256: string; kind: string; driftKind: string }>
|
||||||
@@ -21,21 +22,25 @@ export async function GET() {
|
|||||||
byServer.set(e.server, arr);
|
byServer.set(e.server, arr);
|
||||||
}
|
}
|
||||||
return json({
|
return json({
|
||||||
totalDrift: entries.length,
|
totalDrift: entries.length, scannedAt: plan.scannedAt,
|
||||||
servers: [...byServer.entries()].map(([server, files]) => ({ server, files }))
|
servers: [...byServer.entries()].map(([server, files]) => ({ server, files }))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST removes drift and creates a drift deployment. Body: { servers?: string[], dryRun?: boolean }
|
// POST removes drift and creates a drift deployment. Body: { servers?: string[], dryRun?: boolean }
|
||||||
export async function POST({ request }: { request: Request }) {
|
export async function POST({ request }: { request: Request }) {
|
||||||
const body = (await request
|
const body = await request.json().catch(() => null);
|
||||||
.json()
|
if (!body || (body.servers !== undefined && (!Array.isArray(body.servers) || !body.servers.every((server: unknown) => typeof server === 'string')))) {
|
||||||
.catch(() => ({}))) as { servers?: string[]; dryRun?: boolean };
|
return json({ ok: false, error: 'Provide the server IDs to review.' }, { status: 400 });
|
||||||
const result = await runDriftDeployment({
|
}
|
||||||
servers: body.servers,
|
try {
|
||||||
dryRun: body.dryRun ?? false,
|
if (body.dryRun === true) return json({ ok: true, plan: await previewDrift(body.servers) });
|
||||||
triggeredBy: 'ui'
|
if (typeof body.expectedFingerprint !== 'string' || !body.expectedFingerprint) return json({ ok: false, error: 'Review a reconciliation plan before applying changes.' }, { status: 400 });
|
||||||
});
|
const result = await runDriftDeployment({ servers: body.servers, expectedFingerprint: body.expectedFingerprint, triggeredBy: 'ui' });
|
||||||
if (result.deploymentId) void kick();
|
if (result.deploymentId) void kick();
|
||||||
return json({ ok: true, result });
|
return json({ ok: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DriftPlanChanged) return json({ ok: false, error: error.message, plan: error.plan }, { status: 409 });
|
||||||
|
return json({ ok: false, error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export async function GET({ params }: { params: { id: string } }) {
|
|||||||
return json({
|
return json({
|
||||||
script,
|
script,
|
||||||
versions,
|
versions,
|
||||||
|
installedVersions: script.kind === 'script' || script.kind === 'plugin' ? db().prepare(`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`).all(id, script.target_rel_path) : [],
|
||||||
matchedServers: matched,
|
matchedServers: matched,
|
||||||
runningBetaId: runningBeta?.id ?? null
|
runningBetaId: runningBeta?.id ?? null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getScript, setScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
import { getScript, setScope, serversMatchingScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
|
||||||
import { unsetScopeAndDetach } from '$lib/server/fleet/beta.js';
|
import { unsetScopeAndDetach } from '$lib/server/fleet/beta.js';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
|
||||||
|
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
|
||||||
|
const id = Number(params.id);
|
||||||
|
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid file ID.' }, { status: 400 });
|
||||||
|
const script = getScript(id);
|
||||||
|
if (!script) return json({ error: 'File not found.' }, { status: 404 });
|
||||||
|
const type = url.searchParams.get('type');
|
||||||
|
const value = url.searchParams.get('value');
|
||||||
|
if (type !== 'none' && type !== 'game' && type !== 'map' || type !== 'none' && !value) return json({ error: 'Choose a complete scope.' }, { status: 400 });
|
||||||
|
const before = serversMatchingScope(script.scope_type, script.scope_value);
|
||||||
|
const after = type === 'none' ? [] : serversMatchingScope(type as ScopeType, value);
|
||||||
|
const composed = script.kind === 'config' || script.kind === 'baseline';
|
||||||
|
const tracked = composed ? [] : (db().prepare(`SELECT DISTINCT server FROM server_scripts WHERE rel_path = ? AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?) ORDER BY server`).all(script.target_rel_path, script.id) as Array<{ server: string }>).map(row => row.server);
|
||||||
|
return json({ before, after, added: after.filter(name => !before.includes(name)), removed: before.filter(name => !after.includes(name)),
|
||||||
|
affected: composed ? [...new Set([...before, ...after])].sort() : type === 'none' ? tracked : [...new Set([...before, ...after])].sort(),
|
||||||
|
composed, detached: type === 'none' ? tracked : [] }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
|
}
|
||||||
|
|
||||||
function parseId(raw: string): number | null {
|
function parseId(raw: string): number | null {
|
||||||
const n = Number.parseInt(raw, 10);
|
const n = Number.parseInt(raw, 10);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
||||||
|
import { listServers } from '$lib/server/fleet/servers.js';
|
||||||
|
import { listServerKeys } from '$lib/server/fleet/server-keys.js';
|
||||||
|
import { classifyHealth } from '$lib/server/fleet/health.js';
|
||||||
|
import { tcpProbe } from '$lib/server/integrations/netProbe.js';
|
||||||
|
import type { SetupCheck, SystemStatus } from '$lib/system-status.js';
|
||||||
|
|
||||||
|
export async function GET({ url }: { url: URL }) {
|
||||||
|
const servers = listServers();
|
||||||
|
const readyServers = servers.filter(server => classifyHealth(server.last_seen_at, server.last_error, server.container_state) === 'online').length;
|
||||||
|
const savedKeys = listServerKeys().length;
|
||||||
|
const missing = [!config.komodo.address && 'KOMODO_ADDRESS', !config.komodo.apiKey && 'KOMODO_API_KEY', !config.komodo.apiSecret && 'KOMODO_API_SECRET'].filter(Boolean);
|
||||||
|
const connection: SetupCheck = { id: 'komodo', title: 'Komodo connection', state: komodoConfigured() ? 'unverified' : 'unconfigured', detail: komodoConfigured() ? 'Credentials configured. Run connection check to verify host reachability.' : `Set ${missing.join(', ')} in the app environment.`, href: '#setup-guide', action: 'Setup instructions' };
|
||||||
|
if (komodoConfigured() && url.searchParams.get('probe') === '1') {
|
||||||
|
try {
|
||||||
|
const address = new URL(config.komodo.address);
|
||||||
|
if (!['http:', 'https:'].includes(address.protocol)) throw new Error('invalid address');
|
||||||
|
const result = await Promise.race([
|
||||||
|
tcpProbe('Komodo', address.hostname, Number(address.port || (address.protocol === 'https:' ? 443 : 80)), 3000),
|
||||||
|
new Promise<null>(resolve => setTimeout(() => resolve(null), 4000))
|
||||||
|
]);
|
||||||
|
const reachable = result?.outcome === 'connected';
|
||||||
|
connection.state = reachable ? 'ready' : 'attention';
|
||||||
|
connection.detail = reachable ? 'Host reachable. This network check does not validate API credentials; deployment previews check launch prerequisites.' : 'Host unreachable from Electric Cherry. Check the address, DNS, port, and network access.';
|
||||||
|
} catch { connection.state = 'attention'; connection.detail = 'Komodo address is invalid or could not be reached. Check KOMODO_ADDRESS.'; }
|
||||||
|
}
|
||||||
|
const checks: SetupCheck[] = [
|
||||||
|
connection,
|
||||||
|
{ id: 'target', title: 'Deployment host', state: config.komodo.targetServer ? 'ready' : 'unconfigured', detail: config.komodo.targetServer ? `New servers target ${config.komodo.targetServer}.` : 'Set KOMODO_TARGET_SERVER to the exact host name shown in Komodo.', href: '#setup-guide', action: 'Setup instructions' },
|
||||||
|
{ id: 'rcon', title: 'Game server connection', state: !rconConfigured() ? 'unconfigured' : readyServers ? 'ready' : 'unverified', detail: !rconConfigured() ? 'Set RCON_PASSWORD to the password used by your game servers.' : readyServers ? `${readyServers} servers have fresh successful RCON reports.` : 'RCON password configured. A running server is needed to verify a response.', href: servers.length ? '/' : '#setup-guide', action: servers.length ? 'Review fleet' : 'Setup instructions' },
|
||||||
|
{ id: 'game-files', title: 'Game files on the host', state: 'unverified', detail: 'T5 and T6 game-file volumes must be populated on the Komodo host. Their contents cannot be verified from this app.', href: '#game-files', action: 'Game-file instructions' },
|
||||||
|
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Key presence does not confirm validity with Plutonium.`, href: '/server-keys', action: savedKeys ? 'Manage keys' : 'Add a key' },
|
||||||
|
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Servers are registered but none has a fresh healthy report. Open diagnostics to investigate.' : 'Create your first server after completing the setup checks.', href: servers.length ? '/' : '/deployments', action: servers.length ? 'Review fleet' : 'Create a server' }
|
||||||
|
];
|
||||||
|
const body: SystemStatus = { checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys };
|
||||||
|
return json(body, { headers: { 'cache-control': 'no-store' } });
|
||||||
|
}
|
||||||
@@ -9,11 +9,16 @@
|
|||||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||||
|
import { statusPresentation } from '$lib/ui/status.js';
|
||||||
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||||
interface BetaListRow {
|
interface BetaListRow {
|
||||||
id: number;
|
id: number;
|
||||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
||||||
testOutcome: string | null;
|
testOutcome: string | null;
|
||||||
|
testReason: string | null;
|
||||||
|
deadlineAt: string | null;
|
||||||
|
notes: string | null;
|
||||||
wait_for_health: number;
|
wait_for_health: number;
|
||||||
wait_for_match: number;
|
wait_for_match: number;
|
||||||
opened_at: string;
|
opened_at: string;
|
||||||
@@ -33,10 +38,22 @@
|
|||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
|
const active = (beta: BetaListRow) => beta.state === 'running' || beta.state === 'ready-promote';
|
||||||
const tabs = $derived([{id:'active',label:'Active',badge:betas.filter(active).length},{id:'history',label:'History',badge:betas.filter(b => !active(b)).length}]);
|
const tabs = $derived([{id:'active',label:'Active',badge:betas.filter(active).length},{id:'history',label:'History',badge:betas.filter(b => !active(b)).length}]);
|
||||||
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
|
let now = $state(Date.now());
|
||||||
|
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + (b.notes ?? '') + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
|
||||||
|
function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; }
|
||||||
|
function blocker(beta:BetaListRow) {
|
||||||
|
if(beta.state==='promoted')return 'Version promoted; check its rollout for remaining activation.';
|
||||||
|
if(beta.state==='rolled-back')return 'Rolled back. The files and plan can be reused for another attempt.';
|
||||||
|
if(beta.state==='ready-promote')return 'Ready for promotion review.';
|
||||||
|
if(beta.testReason)return beta.testReason;
|
||||||
|
const problem=beta.servers.find(server=>server.error);
|
||||||
|
if(problem)return `${serverTitle(problem.server)}: ${problem.error}`;
|
||||||
|
const waiting=beta.servers.find(server=>server.stage!=='ready');
|
||||||
|
return waiting?`${serverTitle(waiting.server)}: ${statusPresentation(waiting.stage).label.toLowerCase()}.`:'Waiting for test evaluation.';
|
||||||
|
}
|
||||||
$effect(() => { search; tab; limit = 25; });
|
$effect(() => { search; tab; limit = 25; });
|
||||||
async function load() {
|
async function load() {
|
||||||
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; error = ''; }
|
try { const res = await fetch('/api/betas'); if (!res.ok) throw new Error('Could not refresh betas.'); betas = (await res.json()).betas; now=Date.now(); error = ''; }
|
||||||
catch (e) { error = (e as Error).message; } finally { loading = false; }
|
catch (e) { error = (e as Error).message; } finally { loading = false; }
|
||||||
}
|
}
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -57,8 +74,8 @@
|
|||||||
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
|
{:else if !filtered.length && !error}<EmptyState title={search ? 'No matching betas' : tab === 'active' ? 'No active betas' : 'No beta history'} description={tab === 'active' && !search ? 'Start a beta when you have a version ready to test.' : undefined} />
|
||||||
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
|
{:else}<div class="rows">{#each filtered.slice(0,limit) as beta (beta.id)}
|
||||||
<a href={'/betas/' + beta.id} class="row">
|
<a href={'/betas/' + beta.id} class="row">
|
||||||
<div><strong>Beta #{beta.id}</strong><span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span></div>
|
<div class="identity"><strong>{beta.notes || `Beta #${beta.id}`}</strong>{#if beta.notes}<span class="beta-id"> · #{beta.id}</span>{/if}<span class="files">{beta.scripts.map(s => s.name + ' · ' + artifactVersionLabel(s.version)).join(', ')}</span><span class="blocker">{blocker(beta)}</span><span class="timing"><time datetime={beta.opened_at} title={new Date(beta.opened_at).toLocaleString()}>Opened {age(beta.opened_at)}</time>{#if beta.deadlineAt && active(beta)} · Ends {new Date(beta.deadlineAt).toLocaleString()}{:else if beta.closed_at} · Finished {new Date(beta.closed_at).toLocaleString()}{/if}</span></div>
|
||||||
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.length} servers</span></div>
|
<div class="meta"><StatusBadge state={beta.state} />{#if beta.testOutcome && beta.testOutcome !== 'active'}<StatusBadge state={beta.testOutcome} />{/if}<span>{beta.servers.filter(server=>server.stage==='ready').length} / {beta.servers.length} servers passed</span></div>
|
||||||
</a>
|
</a>
|
||||||
{/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if}
|
{/each}</div><ListFooter total={filtered.length} bind:limit label="betas" />{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -70,4 +87,5 @@
|
|||||||
.row:hover { border-color:var(--border-strong); }
|
.row:hover { border-color:var(--border-strong); }
|
||||||
.files { display:block; margin-top:6px; font-size:13px; color:var(--text-secondary); overflow-wrap:anywhere; }
|
.files { display:block; margin-top:6px; font-size:13px; color:var(--text-secondary); overflow-wrap:anywhere; }
|
||||||
.meta { display:flex; align-items:center; flex-wrap:wrap; gap:12px; font-size:12px; color:var(--text-secondary); }
|
.meta { display:flex; align-items:center; flex-wrap:wrap; gap:12px; font-size:12px; color:var(--text-secondary); }
|
||||||
|
.identity { flex:1 1 320px; min-width:0; } .blocker,.timing { display:block; margin-top:8px; line-height:1.5; font-size:13px; overflow-wrap:anywhere; } .timing,.beta-id { color:var(--text-secondary); font-size:12px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+145
-333
@@ -1,375 +1,187 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
|
||||||
import BetaTestProgress from "$ui/components/BetaTestProgress.svelte";
|
|
||||||
import type { BetaTestView } from "$lib/beta-plan.js";
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
|
import type { BetaTestView } from '$lib/beta-plan.js';
|
||||||
|
import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
|
||||||
|
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||||
|
import { betaFlow, type ActivityEvent } from '$lib/operation-flow.js';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
|
import BetaTestProgress from '$ui/components/BetaTestProgress.svelte';
|
||||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||||
|
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
||||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
import { page } from '$app/stores';
|
|
||||||
import Card from '$ui/components/primitives/Card.svelte';
|
import Card from '$ui/components/primitives/Card.svelte';
|
||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import OperationHistory from '$ui/components/OperationHistory.svelte';
|
|
||||||
import { betaFlow, type ActivityEvent } from '$lib/operation-flow.js';
|
|
||||||
|
|
||||||
interface BetaRow {
|
|
||||||
id: number; script_id: number; script_version_id: number;
|
|
||||||
trigger_kind: string; wait_for_health: number; wait_for_match: number;
|
|
||||||
state: 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
|
|
||||||
opened_at: string; closed_at: string | null; notes: string | null;
|
|
||||||
}
|
|
||||||
interface BetaServerRow {
|
|
||||||
beta_id: number; server: string;
|
|
||||||
had_original: number; prior_version_id: number | null;
|
|
||||||
stage: string; error: string | null;
|
|
||||||
deployed_at: string | null; restarted_at: string | null;
|
|
||||||
healthy_at: string | null; match_seen_at: string | null;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
interface BetaScript {
|
|
||||||
id: number; name: string; target_rel_path: string;
|
|
||||||
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
|
||||||
version: { id: number; version: number; release_version: string | null; sha256: string } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
interface BetaRow { id:number; state:string; opened_at:string; closed_at:string|null; wait_for_health:number; wait_for_match:number; notes?:string|null; }
|
||||||
|
interface BetaServer { server:string; stage:string; error:string|null; deployed_at:string|null; restarted_at:string|null; healthy_at:string|null; match_seen_at:string|null; }
|
||||||
|
interface BetaFile { id:number; name:string; kind:string; target_rel_path:string; version:{id:number; version:number; release_version:string|null; sha256:string}|null; }
|
||||||
|
interface Promotion { id:string; pending:number; failed:number; }
|
||||||
let test = $state<BetaTestView|null>(null);
|
let test = $state<BetaTestView|null>(null);
|
||||||
let beta = $state<BetaRow|null>(null);
|
let beta = $state<BetaRow|null>(null);
|
||||||
let scripts = $state<BetaScript[]>([]);
|
let scripts = $state<BetaFile[]>([]);
|
||||||
let servers = $state<BetaServerRow[]>([]);
|
let servers = $state<BetaServer[]>([]);
|
||||||
let events = $state<ActivityEvent[]>([]);
|
let events = $state<ActivityEvent[]>([]);
|
||||||
let loadError = $state('');
|
let promotion = $state<Promotion|null>(null);
|
||||||
let request = 0;
|
let loading = $state(true), loadError = $state(''), flash = $state(''), success = $state('');
|
||||||
let loading = $state(true);
|
let acting = $state<string|null>(null), request = 0;
|
||||||
let flash = $state<string | null>(null);
|
let reviewOpen = $state(false), preview = $state<BetaPromotionPreview|null>(null), reviewError = $state('');
|
||||||
let acting = $state<string | null>(null);
|
let now = $state(Date.now());
|
||||||
|
const id = $derived(Number($page.params.id));
|
||||||
|
const closed = $derived(beta?.state === 'promoted' || beta?.state === 'rolled-back');
|
||||||
|
const deadline = $derived(beta && test ? new Date(Date.parse(beta.opened_at) + test.plan.timeoutHours * 3600000) : null);
|
||||||
|
const remaining = $derived(deadline ? Math.max(0, Math.ceil((deadline.getTime() - now) / 60000)) : 0);
|
||||||
|
const deadlineText = $derived(remaining > 0 ? `${Math.floor(remaining / 60)}h ${remaining % 60}m remaining` : 'Testing window ended');
|
||||||
|
const passed = $derived(test?.servers.filter(server => server.passed).length ?? servers.filter(server => server.stage === 'ready').length);
|
||||||
|
const ranked = $derived([...servers].sort((a,b) => (a.stage === 'failed' ? 0 : a.stage === 'ready' ? 2 : 1) - (b.stage === 'failed' ? 0 : b.stage === 'ready' ? 2 : 1) || a.server.localeCompare(b.server)));
|
||||||
|
const waiting = $derived(ranked.filter(server => server.stage !== 'ready'));
|
||||||
|
const nextAction = $derived(closed ? beta?.state === 'promoted' ? 'Follow the rollout to check activation on additional servers.' : 'Reuse these files and this plan when you are ready to test again.'
|
||||||
|
: test?.outcome === 'inconclusive' ? 'The deadline passed without enough evidence. Roll back, then reuse this plan with a longer testing window.'
|
||||||
|
: test?.outcome === 'paused' ? 'Resume to continue collecting evidence, or start a fresh attempt. The original deadline still applies.'
|
||||||
|
: test?.outcome === 'failed' ? 'Investigate the failed check before a fresh attempt, or roll back.'
|
||||||
|
: beta?.state === 'ready-promote' ? 'All required coverage passed. Review the full promotion impact before deploying to additional servers.'
|
||||||
|
: 'Review the outstanding conditions below. Promotion becomes available when the required coverage passes.');
|
||||||
|
|
||||||
let id = $derived(Number.parseInt($page.params.id ?? '0', 10));
|
async function load() {
|
||||||
|
const current = ++request, requestedId = id;
|
||||||
async function load(): Promise<void> {
|
|
||||||
const current = ++request;
|
|
||||||
const requestedId = id;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/betas/${requestedId}`);
|
const res = await fetch(`/api/betas/${requestedId}`);
|
||||||
if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta.');
|
if (!res.ok) throw new Error(res.status === 404 ? 'Beta not found.' : 'Could not refresh beta. Showing the last loaded results.');
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (current !== request || requestedId !== id) return;
|
if (current !== request || requestedId !== id) return;
|
||||||
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events;
|
test = body.test; beta = body.beta; scripts = body.scripts; servers = body.servers; events = body.events; promotion = body.promotion ?? null;
|
||||||
loadError = '';
|
now = Date.now(); loadError = '';
|
||||||
} catch (err) {
|
} catch (error) { if (current === request && requestedId === id) loadError = (error as Error).message; }
|
||||||
if (current === request && requestedId === id) loadError = (err as Error).message;
|
finally { if (current === request && requestedId === id) loading = false; }
|
||||||
} finally { if (current === request && requestedId === id) loading = false; }
|
|
||||||
}
|
}
|
||||||
|
$effect(() => { id; beta = null; loading = true; loadError = ''; flash = ''; success = ''; reviewOpen = false; void load(); });
|
||||||
$effect(() => { id; beta = null; loading = true; loadError = ''; void load(); });
|
|
||||||
|
|
||||||
onMount(() => pollVisible(load, 5000, false));
|
onMount(() => pollVisible(load, 5000, false));
|
||||||
|
async function reviewPromotion() {
|
||||||
async function promote(): Promise<void> {
|
reviewOpen = true; preview = null; reviewError = ''; acting = 'preview';
|
||||||
acting = 'promote'; flash = null;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/betas/${id}/promote`, { method: 'POST' });
|
const response = await fetch(`/api/betas/${id}/promote`), body = await response.json();
|
||||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
if (!response.ok) throw new Error(body.error ?? 'Could not prepare promotion review.');
|
||||||
if (!body.ok) { flash = body.error ?? 'Promote failed'; return; }
|
preview = body.preview;
|
||||||
await load();
|
} catch (error) { reviewError = (error as Error).message; }
|
||||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
finally { acting = null; }
|
||||||
}
|
}
|
||||||
async function rollback(): Promise<void> {
|
async function promote() {
|
||||||
const isReject = beta?.state === 'ready-promote';
|
if (!preview) return;
|
||||||
const prompt = isReject
|
acting = 'promote'; reviewError = ''; flash = ''; success = '';
|
||||||
? `Reject beta #${id}? This restores original files on every beta server and marks the version as test-failed.`
|
|
||||||
: `Rollback beta #${id}? This restores original files on every beta server.`;
|
|
||||||
const restart = test ? test.plan.rollbackRestart === 'when-empty' ? ' Restarts will roll out when servers are empty.' : ' Restart servers manually to load the restored files.' : '';
|
|
||||||
if (!await confirmAction({title:isReject ? 'Reject beta?' : 'Roll back beta?',message:prompt + restart,confirmLabel:isReject ? 'Reject beta' : 'Roll back',danger:true})) return;
|
|
||||||
acting = 'rollback'; flash = null;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' });
|
const response = await fetch(`/api/betas/${id}/promote`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({previewToken:preview.token})});
|
||||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
const body = await response.json();
|
||||||
if (!body.ok) { flash = body.error ?? 'Rollback failed'; return; }
|
if (!response.ok || !body.ok) {
|
||||||
await load();
|
if (body.kind === 'preview-changed' && body.details) preview = body.details;
|
||||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
throw new Error(body.error ?? 'Promotion could not be started.');
|
||||||
}
|
}
|
||||||
async function retry(server: string): Promise<void> {
|
reviewOpen = false;
|
||||||
acting = `retry:${server}`; flash = null;
|
success = `Promotion created. ${body.result.awaitingActivation ?? 0} additional servers await activation${body.result.failedServers?.length ? `; ${body.result.failedServers.length} servers need attention` : ''}.`;
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/betas/${id}/retry`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ server })
|
|
||||||
});
|
|
||||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
|
||||||
if (!body.ok) { flash = body.error ?? 'Retry failed'; return; }
|
|
||||||
await load();
|
await load();
|
||||||
} catch (error) { flash = (error as Error).message; } finally { acting = null; }
|
} catch (error) { reviewError = (error as Error).message; }
|
||||||
|
finally { acting = null; }
|
||||||
|
}
|
||||||
|
async function rollback() {
|
||||||
|
const reject = beta?.state === 'ready-promote';
|
||||||
|
const restarts = test?.plan.rollbackRestart === 'manual' ? 'Restart servers manually to load the restored files.' : 'Restarts wait for empty servers.';
|
||||||
|
if (!await confirmAction({title:reject?'Reject beta?':'Roll back beta?',message:`Restore the original files on ${servers.length} beta servers${reject ? ' and mark these versions as test-failed' : ''}. ${restarts}`,confirmLabel:reject?'Reject beta':'Roll back',danger:true})) return;
|
||||||
|
acting = 'rollback'; flash = ''; success = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/betas/${id}/rollback`,{method:'POST'}), body = await response.json();
|
||||||
|
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Rollback failed.');
|
||||||
|
success = `Original files restored. ${restarts}`; await load();
|
||||||
|
} catch(error) { flash = (error as Error).message; }
|
||||||
|
finally { acting = null; }
|
||||||
|
}
|
||||||
|
async function retry(server:string) {
|
||||||
|
acting = `retry:${server}`; flash = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/betas/${id}/retry`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({server})}), body = await response.json();
|
||||||
|
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Retry failed.');
|
||||||
|
await load();
|
||||||
|
} catch(error) { flash = (error as Error).message; }
|
||||||
|
finally { acting = null; }
|
||||||
|
}
|
||||||
|
function outstanding(server:string) {
|
||||||
|
const progress = test?.servers.find(row => row.server === server)?.progress.find(item => !item.passed);
|
||||||
|
const requirement = test?.plan.requirements.find(item => item.id === progress?.id);
|
||||||
|
return requirement ? requirementSummary(requirement) : 'Waiting for deployment and fresh observations.';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Beta #{id} | Electric Cherry</title></svelte:head>
|
<svelte:head><title>Beta #{id} | Electric Cherry</title></svelte:head>
|
||||||
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={load}>Retry</button></p>{/if}
|
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||||
|
{#if loading}<EmptyState title="Loading beta…" />
|
||||||
{#if loading}
|
{:else if !beta && !loadError}<EmptyState title="Beta not found" description="Return to Betas to find another test." />
|
||||||
<div class="loading">Loading…</div>
|
{:else if beta}
|
||||||
{:else if !beta}
|
|
||||||
<EmptyState title="Beta not found" description="This beta may have been archived." />
|
|
||||||
{:else}
|
|
||||||
{@const b = beta}
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Beta #{b.id}">
|
<SectionHeader title={beta.notes || `Beta #${beta.id}`} subtitle={beta.notes ? `Beta #${beta.id}` : undefined}>
|
||||||
{#snippet crumbs()}
|
{#snippet crumbs()}<Breadcrumbs items={[{label:'Betas',href:'/betas'},{label:`#${id}`}]} />{/snippet}
|
||||||
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: `#${b.id}` }]} />
|
{#snippet actions()}<StatusBadge state={test && !closed ? test.outcome : beta?.state ?? 'running'} />{/snippet}
|
||||||
{/snippet}
|
|
||||||
{#snippet actions()}
|
|
||||||
<StatusBadge state={b.state} />
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div class="opts">
|
<div class="summary-heading"><h2>{closed ? beta.state === 'promoted' ? 'Version promoted' : 'Beta rolled back' : `${passed} / ${servers.length} servers passed`}</h2>{#if test}<span>{coverageSummary(test.plan)}</span>{/if}</div>
|
||||||
<div class="opt"><span class="opt-k">Started</span><span class="opt-v">{new Date(b.opened_at).toLocaleString()}</span></div>
|
<p>{nextAction}</p>
|
||||||
<div class="opt"><span class="opt-k">Finished</span><span class="opt-v">{b.closed_at ? new Date(b.closed_at).toLocaleString() : 'In progress'}</span></div>
|
{#if test?.reason}<p class="reason">{test.reason}</p>{/if}
|
||||||
|
<div class="timing"><span>Started <time datetime={beta.opened_at}>{new Date(beta.opened_at).toLocaleString()}</time></span>
|
||||||
|
{#if deadline && !closed}<strong>Ends <time datetime={deadline.toISOString()}>{deadline.toLocaleString()}</time> · {deadlineText}</strong>{/if}
|
||||||
|
{#if beta.closed_at}<span>Finished {new Date(beta.closed_at).toLocaleString()}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
|
||||||
|
{#if closed}<Button href={`/betas/new?reuse=${id}`}>Reuse files and test plan</Button>{/if}
|
||||||
|
{#if promotion}<Button href={`/campaigns/${encodeURIComponent(promotion.id)}`}>View rollout</Button>{/if}
|
||||||
|
</div>
|
||||||
|
{#if promotion}<p class="muted">{promotion.pending} servers awaiting activation · {promotion.failed} need attention. Promoting a version does not mean it is already running everywhere.</p>{/if}
|
||||||
|
{#if flash}<p role="alert" class="ui-error">{flash}</p>{/if}
|
||||||
|
{#if success}<p role="status">{success}</p>{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
{#if !closed && waiting.length}
|
||||||
<OperationHistory steps={betaFlow({ ...b, testPlan: !!test }, servers)} {events} active={b.state === 'running' || b.state === 'ready-promote'} />
|
<section aria-label="Outstanding servers"><h2>Outstanding conditions</h2><div class="server-list">
|
||||||
|
{#each waiting as server}<div class="server-row"><a href={`/servers/${encodeURIComponent(server.server)}`}><ServerIdentity name={server.server} /></a><StatusBadge state={server.stage} /><p>{server.error ?? outstanding(server.server)}</p><a href={server.error ? `/servers/${encodeURIComponent(server.server)}?tab=diagnostics` : '#test-results'}>{server.error ? 'Diagnose' : 'View checks'}</a></div>{/each}
|
||||||
<!-- Artifacts -->
|
</div></section>
|
||||||
<Card padding="lg">
|
{/if}
|
||||||
<div class="panel-head">
|
{#if test}<div id="test-results"><Card padding="lg"><BetaTestProgress {test} betaId={id} {closed} deadline={deadline?.toISOString()} retryBlocked={servers.some(server=>server.error?.startsWith('deploy:')) ? 'Deployment was incomplete. Roll back before opening a fresh beta.' : undefined} onUpdate={load} /></Card></div>{/if}
|
||||||
<h2>Artifacts</h2>
|
<details class="ui-panel"><summary>Files and versions ({scripts.length})</summary><div class="file-list">{#each scripts as file}<div><a href={`/${file.kind === 'baseline' ? 'configs' : file.kind + 's'}/${file.id}`}>{file.name}</a><strong>{artifactVersionLabel(file.version)}</strong><code>{file.target_rel_path}</code><span>{file.version?.sha256.slice(0,12)}</span></div>{/each}</div></details>
|
||||||
<Badge variant="neutral" size="sm">{scripts.length}</Badge>
|
<details class="ui-panel"><summary>Deployment progress ({servers.length} servers)</summary><div class="server-list">
|
||||||
</div>
|
{#each ranked as server}<div class="server-row"><a href={`/servers/${encodeURIComponent(server.server)}`}><ServerIdentity name={server.server}/></a><StatusBadge state={server.stage}/><p>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}</p>{#if server.error}<p class="ui-error">{server.error}</p>{/if}{#if server.stage === 'failed' && !test && !closed}<Button size="sm" onclick={() => retry(server.server)} disabled={acting !== null} loading={acting === `retry:${server.server}`}>Retry server</Button>{/if}</div>{/each}
|
||||||
{#if scripts.length === 0}
|
</div></details>
|
||||||
<EmptyState title="No artifacts bundled" description="This beta has no scripts, configs, or plugins." />
|
<OperationHistory steps={betaFlow({...beta,testPlan:!!test},servers)} {events} active={!closed}/>
|
||||||
{:else}
|
|
||||||
<div class="script-list">
|
|
||||||
{#each scripts as s (s.id)}
|
|
||||||
<div class="script-row">
|
|
||||||
<Badge variant="neutral" size="sm" uppercase>{s.kind}</Badge>
|
|
||||||
<a class="s-name" href={"/" + (s.kind === "baseline" ? "configs" : s.kind + "s") + "/" + s.id}>{s.name}</a>
|
|
||||||
<span class="s-target">{s.target_rel_path}</span>
|
|
||||||
<span class="s-ver">{artifactVersionLabel(s.version)}</span>
|
|
||||||
<span class="s-sha">{s.version?.sha256.slice(0, 8) ?? ''}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Card>
|
|
||||||
|
|
||||||
{#if test}
|
<Dialog bind:open={reviewOpen} title="Review promotion" description="Review the exact files and server eligibility before installing the promoted versions." busy={acting === 'promote'} wide>
|
||||||
<Card padding="lg"><BetaTestProgress {test} betaId={id} closed={b.state === "promoted" || b.state === "rolled-back"} onUpdate={load} /></Card>
|
{#if acting === 'preview'}<p role="status">Preparing promotion impact…</p>{/if}
|
||||||
{:else}
|
{#if reviewError}<p class="ui-error" role="alert">{reviewError}</p>{/if}
|
||||||
<!-- Options -->
|
{#if preview}
|
||||||
<Card padding="lg">
|
<p><strong>{preview.servers.filter(server => server.tested).length} tested servers</strong> and <strong>{preview.servers.filter(server => !server.tested).length} additional eligible servers</strong>.</p>
|
||||||
<div class="panel-head"><h2>Options</h2></div>
|
<p>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.</p>
|
||||||
<div class="opts">
|
<p class="muted">Reviewed {new Date(preview.createdAt).toLocaleString()}. If targets, versions, or composed settings change, you must review again.</p>
|
||||||
<div class="opt">
|
{#each preview.blockers as blocker}<p class="ui-error" role="alert">{blocker}</p>{/each}
|
||||||
<span class="opt-k">Wait for health</span>
|
<div class="file-list">{#each preview.files as file}<details open><summary>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary><p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0,12)}</p><ul>{#each file.servers as name}<li><ServerIdentity {name}/><span>{preview.servers.find(server => server.name === name)?.tested ? 'Already tested' : 'Additional target · restart when empty'}</span></li>{/each}</ul></details>{/each}</div>
|
||||||
<span class="opt-v">{b.wait_for_health ? 'Yes' : 'No'}</span>
|
{#if preview.configWrites.length}<details><summary>Composed configuration files ({preview.configWrites.length})</summary>{#each preview.configWrites as write}<p>{write.server} · {write.path} · {write.sha256?.slice(0,12) ?? write.error}</p>{/each}</details>{/if}
|
||||||
</div>
|
{:else if acting !== 'preview'}<Button onclick={reviewPromotion}>Retry preview</Button>{/if}
|
||||||
<div class="opt">
|
{#snippet actions()}<Button onclick={() => reviewOpen = false} disabled={acting === 'promote'}>Cancel</Button>{#if preview}<Button onclick={reviewPromotion} disabled={acting !== null}>Refresh review</Button><Button variant="primary" onclick={promote} loading={acting === 'promote'} disabled={acting !== null || !!preview.blockers.length}>Promote to {preview.servers.length} servers</Button>{/if}{/snippet}
|
||||||
<span class="opt-k">Wait for match</span>
|
</Dialog>
|
||||||
<span class="opt-v">{b.wait_for_match ? 'Yes' : 'No'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Server progress -->
|
|
||||||
<Card padding="lg">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>Server progress</h2>
|
|
||||||
<Badge variant="neutral" size="sm">{servers.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="srv-list">
|
|
||||||
{#each servers as bs (bs.server)}
|
|
||||||
<div class="srv-row">
|
|
||||||
<a class="srv-name" href="/servers/{bs.server}"><ServerIdentity name={bs.server} /></a>
|
|
||||||
<StatusBadge state={bs.stage} />
|
|
||||||
<div class="ticks" aria-label="pipeline progress">
|
|
||||||
<span class:on={bs.deployed_at}>Deploy</span>
|
|
||||||
<span class:on={bs.restarted_at || bs.stage === 'restarting'}>Empty</span>
|
|
||||||
<span class:on={bs.restarted_at}>Restart</span>
|
|
||||||
{#if b.wait_for_health}<span class:on={bs.healthy_at}>Healthy</span>{/if}
|
|
||||||
{#if b.wait_for_match}
|
|
||||||
<span class:on={bs.match_seen_at}>Match</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if bs.error}
|
|
||||||
<span class="err">{bs.error}</span>
|
|
||||||
{/if}
|
|
||||||
{#if bs.stage === 'failed' && !test}
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => retry(bs.server)}
|
|
||||||
disabled={acting === `retry:${bs.server}`}
|
|
||||||
loading={acting === `retry:${bs.server}`}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<Card padding="lg">
|
|
||||||
<div class="panel-head"><h2>Actions</h2></div>
|
|
||||||
<div class="action-row">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onclick={promote}
|
|
||||||
disabled={b.state !== 'ready-promote' || acting !== null}
|
|
||||||
loading={acting === 'promote'}
|
|
||||||
title={b.state !== 'ready-promote' ? 'The test plan must pass before promotion' : ''}
|
|
||||||
>
|
|
||||||
Promote
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onclick={rollback}
|
|
||||||
disabled={b.state === 'promoted' || b.state === 'rolled-back' || acting !== null}
|
|
||||||
loading={acting === 'rollback'}
|
|
||||||
>
|
|
||||||
{b.state === 'ready-promote' ? 'Reject' : 'Rollback'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{#if flash}<div class="flash">{flash}</div>{/if}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
.page { display:grid; gap:24px; }
|
||||||
.loading { text-align: center; padding: var(--space-16); color: var(--text-tertiary); }
|
h2 { margin:0; font-size:18px; } p { line-height:1.6; overflow-wrap:anywhere; }
|
||||||
|
.summary-heading,.actions,.timing { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||||
.panel-head {
|
.summary-heading { justify-content:space-between; } .timing,.muted,.summary-heading span { color:var(--text-secondary); font-size:13px; }
|
||||||
display: flex;
|
.timing strong { color:var(--text-primary); } .actions { margin-top:16px; } .reason { border-left:3px solid var(--state-warning); padding-left:12px; }
|
||||||
align-items: center;
|
.server-list,.file-list { display:grid; gap:12px; margin-top:16px; }
|
||||||
justify-content: space-between;
|
.server-row { display:grid; grid-template-columns:minmax(180px,1fr) auto; gap:8px 16px; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||||
gap: var(--space-3);
|
.server-row p { margin:0; font-size:13px; } .server-row a,.file-list a { color:var(--accent-primary-hover); }
|
||||||
margin-bottom: var(--space-4);
|
.file-list > div { display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
||||||
}
|
.file-list code { overflow-wrap:anywhere; } .file-list li { margin:10px 0; } .file-list li span { margin-left:12px; font-size:12px; color:var(--text-secondary); }
|
||||||
.panel-head h2 {
|
details > summary { cursor:pointer; font-weight:600; } #test-results { scroll-margin-top:80px; }
|
||||||
margin: 0;
|
@media(max-width:600px) { .server-row { grid-template-columns:1fr; } .timing { align-items:flex-start; flex-direction:column; } }
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Artifacts */
|
|
||||||
.script-list { display: flex; flex-direction: column; gap: var(--space-1); }
|
|
||||||
.script-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(160px, 220px) 1fr 80px 100px;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
.s-name { color: var(--text-primary); text-decoration: none; font-weight: var(--weight-semibold); }
|
|
||||||
.s-name:hover { color: var(--accent-primary-hover); text-decoration: underline; }
|
|
||||||
.s-target { color: var(--text-secondary); font-family: var(--font-mono); font-size: var(--text-xs); word-break: break-all; }
|
|
||||||
.s-ver, .s-sha { color: var(--text-tertiary); font-family: var(--font-mono); font-size: var(--text-xs); }
|
|
||||||
|
|
||||||
/* Options */
|
|
||||||
.opts { display: flex; gap: var(--space-6); flex-wrap: wrap; }
|
|
||||||
.opt {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
.opt-k {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: var(--tracking-label);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
}
|
|
||||||
.opt-v { font-size: var(--text-md); color: var(--text-primary); font-weight: var(--weight-semibold); }
|
|
||||||
|
|
||||||
/* Server progress */
|
|
||||||
.srv-list { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
||||||
.srv-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(160px, 220px) auto 1fr auto auto;
|
|
||||||
gap: var(--space-3);
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
.srv-name { color: var(--text-primary); text-decoration: none; font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
|
||||||
.srv-name:hover { color: var(--accent-primary-hover); }
|
|
||||||
|
|
||||||
/* Pipeline ticks */
|
|
||||||
.ticks {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-4);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
.ticks span { position: relative; padding-left: 14px; }
|
|
||||||
.ticks span::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--text-quaternary);
|
|
||||||
background: transparent;
|
|
||||||
transition: background-color var(--dur-base) var(--ease-standard), border-color var(--dur-base) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.ticks span.on { color: var(--text-primary); }
|
|
||||||
.ticks span.on::before {
|
|
||||||
background: var(--state-online);
|
|
||||||
border-color: var(--state-online);
|
|
||||||
box-shadow: 0 0 5px rgba(124, 212, 255, 0.45);
|
|
||||||
}
|
|
||||||
.err {
|
|
||||||
color: var(--state-danger);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Actions */
|
|
||||||
.action-row { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
|
||||||
.flash {
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: rgba(255, 86, 112, 0.06);
|
|
||||||
border: 1px solid rgba(255, 86, 112, 0.28);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--state-danger);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.script-row, .srv-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+192
-389
@@ -1,422 +1,225 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BetaPlanBuilder from "$ui/components/BetaPlanBuilder.svelte";
|
|
||||||
import { defaultPlan, parseBetaPlan } from "$lib/beta-plan.js";
|
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
import { defaultPlan, parseBetaPlan, betaPlanIssues, type BetaPlan } from '$lib/beta-plan.js';
|
||||||
|
import { betaDraftKey, readBetaDraft, compatibleBetaServers, type BetaDraft } from '$lib/beta-draft.js';
|
||||||
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
|
import { coverageSummary, requirementSummary } from '$lib/beta-plan-summary.js';
|
||||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
import { goto } from '$app/navigation';
|
import { parseBetaSelection, mapLabel } from '$lib/script-library.js';
|
||||||
|
import { fleetIdentity, freshServer } from '$lib/fleet-overview.js';
|
||||||
import { fleetServers as sharedFleet, fleetError, refreshFleet } from '$ui/stores/fleet.js';
|
import { fleetServers as sharedFleet, fleetError, refreshFleet } from '$ui/stores/fleet.js';
|
||||||
import { parseBetaSelection } from '$lib/script-library.js';
|
import { serverTimeNow } from '$ui/stores/servers.js';
|
||||||
import Card from '$ui/components/primitives/Card.svelte';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
import BetaPlanBuilder from '$ui/components/BetaPlanBuilder.svelte';
|
||||||
|
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import Field from '$ui/components/primitives/Field.svelte';
|
|
||||||
|
|
||||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
interface FileRow { id:number; name:string; kind:string; target_rel_path:string; scope_type:'game'|'map'|null; scope_value:string|null; current_version_id:number|null; latestVersion:{id:number}|null; runningBetaId:number|null; }
|
||||||
interface ScriptListRow {
|
interface Version { id:number; version:number; release_version:string|null; sha256:string; state:string; }
|
||||||
id: number; name: string; target_rel_path: string;
|
interface FileDetail { script:FileRow; versions:Version[]; matchedServers:string[]; runningBetaId?:number|null; }
|
||||||
scope_type: 'game' | 'map' | null; scope_value: string | null;
|
|
||||||
current_version_id: number | null; kind: ArtifactKind;
|
|
||||||
latestVersion: {
|
|
||||||
id: number; version: number; sha256: string;
|
|
||||||
state: 'untested' | 'tested' | 'test-failed';
|
|
||||||
} | null;
|
|
||||||
runningBetaId: number | null;
|
|
||||||
}
|
|
||||||
interface Version { id: number; version: number; release_version: string | null; sha256: string; size: number; state: 'untested' | 'tested' | 'test-failed'; }
|
|
||||||
interface ServerRow { name: string; game: string | null; map: string | null; }
|
|
||||||
interface ScriptDetail { script: ScriptListRow; versions: Version[]; matchedServers: string[]; }
|
|
||||||
interface Pick { key:number; scriptId:number|null; versionId:number|null; }
|
interface Pick { key:number; scriptId:number|null; versionId:number|null; }
|
||||||
|
let scripts = $state<FileRow[]>([]), detailById = $state<Record<number,FileDetail>>({}), nextKey=0;
|
||||||
let loading = $state(true);
|
|
||||||
let scripts = $state<ScriptListRow[]>([]);
|
|
||||||
const fleetServers = $derived($sharedFleet);
|
|
||||||
let detailById = $state<Record<number, ScriptDetail>>({});
|
|
||||||
let nextKey = 0;
|
|
||||||
let picks = $state<Pick[]>([{key:nextKey++,scriptId:null,versionId:null}]);
|
let picks = $state<Pick[]>([{key:nextKey++,scriptId:null,versionId:null}]);
|
||||||
let pickedServers = $state<Set<string>>(new Set());
|
let pickedServers = $state<Set<string>>(new Set()), testPlan = $state<BetaPlan>(defaultPlan());
|
||||||
let testPlan = $state(defaultPlan());
|
let step = $state(0), stepHeading = $state<HTMLHeadingElement>(), planBuilder = $state<BetaPlanBuilder>();
|
||||||
let waitForHealth = $state(true);
|
let loading = $state(true), openingBeta = $state(false), flash = $state(''), notice = $state('');
|
||||||
let waitForMatch = $state(false);
|
let availableDraft = $state<BetaDraft|null>(null), draftReady = $state(false), storageUnavailable = $state(false), allowLeave = false;
|
||||||
let openingBeta = $state(false);
|
let hasLinkedSetup = $state(false);
|
||||||
let flash = $state<string | null>(null);
|
let loadingVersions = $state<Set<number>>(new Set());
|
||||||
|
const rowRequests = new Map<number, number>();
|
||||||
async function load(): Promise<void> {
|
let versionRequest = 0;
|
||||||
const scriptsRes = await fetch('/api/scripts');
|
let betaLabel = $state('');
|
||||||
if (!scriptsRes.ok) throw new Error('Could not load beta setup. Refresh to try again.');
|
let search = $state(''), serverGame = $state(''), availability = $state(''), now = $state(Date.now());
|
||||||
if (scriptsRes.ok) {
|
|
||||||
const sBody = (await scriptsRes.json()) as { scripts: ScriptListRow[] };
|
|
||||||
scripts = sBody.scripts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function initialize() {
|
|
||||||
loading = true; flash = null;
|
|
||||||
try { await load(); await seedSelection(); }
|
|
||||||
catch (error) { flash = (error as Error).message; }
|
|
||||||
finally { loading = false; }
|
|
||||||
}
|
|
||||||
onMount(() => { void initialize(); });
|
|
||||||
const steps = ['Files','Servers','Test plan','Review'];
|
const steps = ['Files','Servers','Test plan','Review'];
|
||||||
let step = $state(0);
|
const fleetServers = $derived($sharedFleet);
|
||||||
let stepHeading = $state<HTMLHeadingElement>();
|
const eligibleFiles = $derived(scripts.filter(file=>file.runningBetaId===null && file.latestVersion!==null));
|
||||||
const filesReady = $derived(picks.length > 0 && picks.every(p => p.scriptId !== null && p.versionId !== null));
|
const filesReady = $derived(picks.length>0 && picks.every(p=>p.scriptId!==null && p.versionId!==null && versionsFor(p).some(v=>v.id===p.versionId)));
|
||||||
async function move(next: number) {
|
const dirty = $derived(!!betaLabel.trim() || picks.some(p=>p.scriptId!==null) || pickedServers.size>0 || JSON.stringify(testPlan)!==JSON.stringify(defaultPlan()));
|
||||||
flash = null;
|
|
||||||
if (next > step && step === 2) {
|
|
||||||
try { parseBetaPlan(testPlan); } catch (error) { flash = (error as Error).message; return; }
|
|
||||||
}
|
|
||||||
step = next; await tick(); stepHeading?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
const eligibleScripts = $derived(
|
|
||||||
scripts.filter(
|
|
||||||
(s) => s.runningBetaId === null && s.latestVersion !== null && (s.latestVersion.id !== s.current_version_id
|
|
||||||
|| picks.some(p => p.scriptId === s.id && p.versionId !== null && p.versionId !== s.current_version_id))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function eligibleForRow(rowIdx: number): ScriptListRow[] {
|
|
||||||
const takenIds = new Set(
|
|
||||||
picks.map((p, i) => (i === rowIdx ? null : p.scriptId)).filter((id): id is number => id !== null)
|
|
||||||
);
|
|
||||||
return eligibleScripts.filter((s) => !takenIds.has(s.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedSelection(): Promise<void> {
|
|
||||||
const query = new URLSearchParams(window.location.search);
|
|
||||||
const selections = parseBetaSelection(query);
|
|
||||||
if (!selections.length) return;
|
|
||||||
// Validate all selections before populating; never substitute a newer upload.
|
|
||||||
const details = await Promise.all(selections.map(async ({ scriptId, versionId }) => {
|
|
||||||
const res = await fetch(`/api/scripts/${scriptId}`);
|
|
||||||
if (!res.ok) throw new Error('A selected artifact is no longer available. Choose artifacts below.');
|
|
||||||
const detail = await res.json() as ScriptDetail & { runningBetaId: number | null };
|
|
||||||
if (detail.runningBetaId !== null || detail.script.current_version_id === versionId
|
|
||||||
|| !detail.versions.some(v => v.id === versionId)) {
|
|
||||||
throw new Error(`${detail.script.name}: the selected version is already promoted, unavailable, or in an active beta. Review your selection below.`);
|
|
||||||
}
|
|
||||||
return detail;
|
|
||||||
}));
|
|
||||||
for (const detail of details) {
|
|
||||||
detailById[detail.script.id] = detail;
|
|
||||||
}
|
|
||||||
picks = selections.map(s => ({ key: nextKey++, ...s }));
|
|
||||||
pickedServers = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPickScript(rowIdx: number): Promise<void> {
|
|
||||||
flash = null;
|
|
||||||
const pick = picks[rowIdx];
|
|
||||||
const scriptId = pick.scriptId;
|
|
||||||
pickedServers = new Set();
|
|
||||||
pick.versionId = null;
|
|
||||||
picks = [...picks];
|
|
||||||
if (scriptId === null) return;
|
|
||||||
if (!detailById[scriptId]) {
|
|
||||||
const res = await fetch(`/api/scripts/${scriptId}`).catch(() => null);
|
|
||||||
if (!res?.ok) { flash = 'Could not load file versions. Choose the file again to retry.'; return; }
|
|
||||||
const body = (await res.json()) as ScriptDetail;
|
|
||||||
detailById = { ...detailById, [scriptId]: body };
|
|
||||||
}
|
|
||||||
if (pick.scriptId !== scriptId) return;
|
|
||||||
const detail = detailById[scriptId];
|
|
||||||
const eligibleVersions = detail.versions.filter((v) => v.id !== detail.script.current_version_id);
|
|
||||||
pick.versionId = eligibleVersions[0]?.id ?? null;
|
|
||||||
picks = [...picks];
|
|
||||||
// Start with nothing selected — user opts in each server explicitly.
|
|
||||||
pickedServers = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRow(): void {
|
|
||||||
picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }];
|
|
||||||
}
|
|
||||||
function removeRow(idx: number): void {
|
|
||||||
picks = picks.filter((_, i) => i !== idx);
|
|
||||||
// Start with nothing selected — user opts in each server explicitly.
|
|
||||||
pickedServers = new Set();
|
|
||||||
}
|
|
||||||
function toggleServer(name: string): void {
|
|
||||||
const next = new Set(pickedServers);
|
|
||||||
if (next.has(name)) next.delete(name);
|
|
||||||
else next.add(name);
|
|
||||||
pickedServers = next;
|
|
||||||
}
|
|
||||||
function versionsForRow(row: Pick): Version[] {
|
|
||||||
if (row.scriptId === null) return [];
|
|
||||||
const detail = detailById[row.scriptId];
|
|
||||||
if (!detail) return [];
|
|
||||||
return detail.versions.filter((v) => v.id !== detail.script.current_version_id);
|
|
||||||
}
|
|
||||||
function candidatesFor(row: Pick): string[] | null {
|
|
||||||
if (row.scriptId === null) return null;
|
|
||||||
const detail = detailById[row.scriptId];
|
|
||||||
if (!detail) return null;
|
|
||||||
return detail.script.scope_type ? detail.matchedServers : fleetServers.map((s) => s.name);
|
|
||||||
}
|
|
||||||
const candidateServers = $derived.by(()=>{
|
const candidateServers = $derived.by(()=>{
|
||||||
const perPick: string[][] = [];
|
const scopes=picks.map(p=>p.scriptId===null?null:detailById[p.scriptId]?.script).filter((file):file is FileRow=>!!file);
|
||||||
for (const p of picks) {
|
return scopes.length ? fleetServers.filter(server=>scopes.every(file=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value)).map(server=>server.name) : [];
|
||||||
const c = candidatesFor(p);
|
});
|
||||||
if (c === null) continue;
|
const visibleServers = $derived(fleetServers.filter(server=>candidateServers.includes(server.name)).filter(server=>{
|
||||||
perPick.push(c);
|
const identity=fleetIdentity(server), fresh=freshServer(server,now);
|
||||||
|
return (!serverGame || identity.game===serverGame) && (!availability || (availability==='empty' ? fresh&&server.last_players===0 : availability==='occupied' ? fresh&&(server.last_players??0)>0 : !fresh))
|
||||||
|
&& `${server.name} ${identity.title} ${identity.map} ${identity.game}`.toLowerCase().includes(search.toLowerCase());
|
||||||
|
}));
|
||||||
|
const groups = $derived([...new Set(visibleServers.map(server=>fleetIdentity(server).group))].map(group=>({key:group,servers:visibleServers.filter(server=>fleetIdentity(server).group===group)})));
|
||||||
|
const forwardReason = $derived(step>0 && $fleetError ? 'Server eligibility could not be verified. Retry servers before continuing; your selections are preserved.' : step===0 ? !filesReady ? 'Choose an available file and exact version in every row.' : '' : step===1 ? !pickedServers.size ? 'Select at least one compatible test server.' : '' : step===2 ? !testPlan.requirements.length ? 'Add at least one requirement to continue.' : '' : !filesReady || !pickedServers.size || [...pickedServers].some(name=>!candidateServers.includes(name)) ? 'Recheck the selected files and servers before starting.' : '');
|
||||||
|
|
||||||
|
function versionsFor(pick:Pick) { return pick.scriptId===null ? [] : (detailById[pick.scriptId]?.versions??[]).filter(v=>v.id!==detailById[pick.scriptId!]?.script.current_version_id); }
|
||||||
|
function eligibleFor(index:number) { return eligibleFiles.filter(file=>!picks.some((p,i)=>i!==index&&p.scriptId===file.id)); }
|
||||||
|
function scopeText(file:FileRow) { return !file.scope_type ? 'Any registered server' : file.scope_type==='map' ? `Map: ${mapLabel(file.scope_value??'')}` : `Game: ${file.scope_value==='t5'?'Black Ops I':file.scope_value==='t6'?'Black Ops II':file.scope_value}`; }
|
||||||
|
function scopeCount(file:FileRow) { return fleetServers.filter(server=>!file.scope_type || (file.scope_type==='game'?server.game:server.map)===file.scope_value).length; }
|
||||||
|
function reconcileSelection() {
|
||||||
|
// Failed refreshes can leave an empty or retained snapshot; neither is
|
||||||
|
// authoritative enough to remove selections from the saved draft.
|
||||||
|
if($fleetError || picks.every(p=>p.scriptId===null) || picks.some(p=>p.scriptId!==null && !detailById[p.scriptId]))return;
|
||||||
|
const result=compatibleBetaServers(pickedServers,candidateServers);
|
||||||
|
if(result.removed.length) { pickedServers=result.kept; notice=`Kept ${result.kept.size} compatible selections. Removed ${result.removed.length} servers that no longer match every file: ${result.removed.join(', ')}.`; }
|
||||||
}
|
}
|
||||||
if (perPick.length === 0) return [] as string[];
|
async function loadSelections(selections:BetaDraft['picks']) {
|
||||||
const [first, ...rest] = perPick;
|
const warnings:string[]=[];
|
||||||
return first.filter((name) => rest.every((set) => set.includes(name)));
|
picks=await Promise.all(selections.map(async selection=>{
|
||||||
});
|
const pick={key:nextKey++,...selection};
|
||||||
|
if(pick.scriptId===null)return pick;
|
||||||
const canSubmit = $derived(
|
const listed=scripts.find(file=>file.id===pick.scriptId);
|
||||||
!openingBeta &&
|
if(!listed || listed.runningBetaId!==null) { warnings.push(`File #${pick.scriptId} is unavailable or in another beta. Choose a replacement.`); return {...pick,scriptId:null,versionId:null}; }
|
||||||
testPlan.requirements.length > 0 &&
|
|
||||||
picks.length > 0 &&
|
|
||||||
picks.every((p) => p.scriptId !== null && p.versionId !== null) &&
|
|
||||||
candidateServers.length > 0 &&
|
|
||||||
pickedServers.size > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
async function openNewBeta(e: Event): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
if (step !== 3 || !canSubmit) return;
|
|
||||||
openingBeta = true;
|
|
||||||
flash = null;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/betas', {
|
const response=await fetch(`/api/scripts/${pick.scriptId}`);
|
||||||
method: 'POST',
|
if(!response.ok)throw Error();
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const detail=await response.json() as FileDetail;
|
||||||
body: JSON.stringify({
|
detailById[pick.scriptId]=detail;
|
||||||
scripts: picks.map((p) => ({ scriptId: p.scriptId!, scriptVersionId: p.versionId! })),
|
if(detail.runningBetaId!=null) { warnings.push(`${listed.name} is in beta #${detail.runningBetaId}. Choose a replacement.`); return {...pick,scriptId:null,versionId:null}; }
|
||||||
servers: [...pickedServers],
|
if(!detail.versions.some(v=>v.id===pick.versionId) || detail.script.current_version_id===pick.versionId) { warnings.push(`${listed.name}: saved version ${pick.versionId??''} is unavailable or already promoted. Select an unpromoted version; no newer version was substituted.`); pick.versionId=null; }
|
||||||
testPlan: parseBetaPlan(testPlan),
|
return pick;
|
||||||
waitForHealth,
|
} catch { warnings.push(`${listed.name}: versions could not be loaded. Choose the file again to retry.`); return {...pick,versionId:null}; }
|
||||||
waitForMatch
|
}));
|
||||||
})
|
if(warnings.length) { flash=warnings.join(' '); step=0; }
|
||||||
});
|
}
|
||||||
const body = (await res.json()) as { ok: boolean; error?: string; result?: { betaId: number } };
|
async function initialize(ignoreDraft = false) {
|
||||||
if (!res.ok || !body.ok) {
|
loading=true; flash=''; draftReady=false;
|
||||||
flash = body.error ?? `HTTP ${res.status}`;
|
try {
|
||||||
|
const response=await fetch('/api/scripts');
|
||||||
|
if(!response.ok)throw new Error('Could not load files for beta setup. Retry to continue.');
|
||||||
|
scripts=(await response.json()).scripts;
|
||||||
|
await refreshFleet();
|
||||||
|
const query=new URLSearchParams(window.location.search), selections=parseBetaSelection(query), reuse=Number(query.get('reuse'));
|
||||||
|
hasLinkedSetup=selections.length>0 || reuse>0;
|
||||||
|
// Returning from a file detail retains the original selection URL. A
|
||||||
|
// stored draft must be offered before that URL can seed a fresh setup.
|
||||||
|
if(!ignoreDraft) {
|
||||||
|
try { availableDraft=readBetaDraft(localStorage.getItem(betaDraftKey)); } catch { storageUnavailable=true; }
|
||||||
|
if(availableDraft) {
|
||||||
|
if(hasLinkedSetup)notice='An unfinished draft is saved. Resume it, or discard it to start from the linked files and exact versions.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (body.result) await goto(`/betas/${body.result.betaId}`);
|
|
||||||
} catch (error) { flash = (error as Error).message; } finally {
|
|
||||||
openingBeta = false;
|
|
||||||
}
|
}
|
||||||
|
if(reuse>0) {
|
||||||
|
const response=await fetch(`/api/betas/${reuse}`);
|
||||||
|
if(!response.ok)throw new Error('The beta to reuse is unavailable. Choose files below.');
|
||||||
|
const source=await response.json();
|
||||||
|
if(!['promoted','rolled-back'].includes(source.beta.state))throw new Error('Roll back or promote the original beta before reusing its files.');
|
||||||
|
if(source.test?.plan)testPlan=parseBetaPlan(source.test.plan);
|
||||||
|
betaLabel=source.beta.notes ?? '';
|
||||||
|
pickedServers=new Set(source.servers.map((s:{server:string})=>s.server));
|
||||||
|
await loadSelections(source.scripts.map((file:{id:number;version:{id:number}|null})=>({scriptId:file.id,versionId:file.version?.id??null})));
|
||||||
|
reconcileSelection(); notice='Copied the exact file versions and test plan. Review availability, server selection, and the testing window before starting.'; draftReady=true;
|
||||||
|
} else if(selections.length) { await loadSelections(selections); draftReady=true; }
|
||||||
|
else draftReady=true;
|
||||||
|
} catch(error) { flash=(error as Error).message; draftReady=true; }
|
||||||
|
finally { loading=false; }
|
||||||
|
}
|
||||||
|
async function resumeDraft() {
|
||||||
|
if(!availableDraft)return;
|
||||||
|
const draft=availableDraft; loading=true; flash=''; notice='';
|
||||||
|
try { testPlan=draft.testPlan; betaLabel=typeof draft.label==='string'?draft.label:''; step=draft.step; pickedServers=new Set(draft.servers); await loadSelections(draft.picks); reconcileSelection(); notice=notice || 'Draft resumed. Exact file versions and compatible servers were rechecked.'; }
|
||||||
|
finally { availableDraft=null; loading=false; draftReady=true; }
|
||||||
|
}
|
||||||
|
function discardDraft() {
|
||||||
|
const useLinked=!!availableDraft && hasLinkedSetup;
|
||||||
|
availableDraft=null; picks=[{key:nextKey++,scriptId:null,versionId:null}]; pickedServers=new Set(); testPlan=defaultPlan(); betaLabel=''; step=0; notice='Draft discarded.';
|
||||||
|
try{localStorage.removeItem(betaDraftKey);}catch{storageUnavailable=true;}
|
||||||
|
draftReady=true;
|
||||||
|
if(useLinked)void initialize(true);
|
||||||
|
}
|
||||||
|
$effect(()=>{
|
||||||
|
if(!draftReady || loading || openingBeta)return;
|
||||||
|
const draft:BetaDraft={version:1,savedAt:new Date().toISOString(),step,label:betaLabel,picks:picks.map(({scriptId,versionId})=>({scriptId,versionId})),servers:[...pickedServers],testPlan:JSON.parse(JSON.stringify(testPlan))};
|
||||||
|
if(dirty) { try{localStorage.setItem(betaDraftKey,JSON.stringify(draft));storageUnavailable=false;}catch{storageUnavailable=true;} }
|
||||||
|
});
|
||||||
|
$effect(()=>{ if(draftReady && !loading && !loadingVersions.size) { candidateServers; reconcileSelection(); } });
|
||||||
|
beforeNavigate(navigation=>{
|
||||||
|
if(!dirty || !storageUnavailable || allowLeave || openingBeta)return;
|
||||||
|
if(navigation.willUnload){navigation.cancel();return;}
|
||||||
|
if(navigation.to){navigation.cancel();void confirmAction({title:'Leave unfinished beta?',message:'This browser cannot save the draft. Leaving will discard your unfinished setup.',confirmLabel:'Leave setup',danger:true}).then(confirmed=>{if(confirmed){allowLeave=true;void goto(navigation.to!.url);}});}
|
||||||
|
});
|
||||||
|
onMount(()=>{void initialize();const timer=setInterval(()=>now=serverTimeNow(),10000);const unload=(event:BeforeUnloadEvent)=>{if(dirty&&storageUnavailable&&!allowLeave&&!openingBeta){event.preventDefault();event.returnValue='';}};window.addEventListener('beforeunload',unload);return()=>{clearInterval(timer);window.removeEventListener('beforeunload',unload);};});
|
||||||
|
async function onPickFile(index:number) {
|
||||||
|
const pick=picks[index], scriptId=pick.scriptId, current=++versionRequest; rowRequests.set(pick.key,current); pick.versionId=null; flash='';
|
||||||
|
if(scriptId===null){loadingVersions=new Set([...loadingVersions].filter(key=>key!==pick.key));reconcileSelection();return;}
|
||||||
|
loadingVersions=new Set([...loadingVersions,pick.key]);
|
||||||
|
try {
|
||||||
|
const response=await fetch(`/api/scripts/${scriptId}`);
|
||||||
|
if(!response.ok)throw new Error('Could not load versions. Choose the file again to retry.');
|
||||||
|
const detail=await response.json() as FileDetail;
|
||||||
|
if(rowRequests.get(pick.key)!==current || pick.scriptId!==scriptId || !picks.some(p=>p.key===pick.key))return;
|
||||||
|
detailById[scriptId]=detail;
|
||||||
|
pick.versionId=versionsFor(pick)[0]?.id??null; picks=[...picks]; reconcileSelection();
|
||||||
|
}catch(error){if(rowRequests.get(pick.key)===current)flash=(error as Error).message;}
|
||||||
|
finally{if(rowRequests.get(pick.key)===current)loadingVersions=new Set([...loadingVersions].filter(key=>key!==pick.key));}
|
||||||
|
}
|
||||||
|
function toggleServer(name:string) { const next=new Set(pickedServers);next.has(name)?next.delete(name):next.add(name);pickedServers=next; }
|
||||||
|
function selectVisible(select:boolean) { const next=new Set(pickedServers);for(const server of visibleServers)select?next.add(server.name):next.delete(server.name);pickedServers=next; }
|
||||||
|
async function move(next:number) {
|
||||||
|
flash='';
|
||||||
|
if(next>step && step===2) { if(!await planBuilder?.validate())return;try{parseBetaPlan(testPlan);}catch(error){flash=(error as Error).message;return;} }
|
||||||
|
step=next;await tick();stepHeading?.focus();
|
||||||
|
}
|
||||||
|
async function openBeta(event:Event) {
|
||||||
|
event.preventDefault();if(step!==3||forwardReason||openingBeta)return;
|
||||||
|
if(betaPlanIssues(testPlan,pickedServers.size).length){await move(2);await planBuilder?.validate();return;}
|
||||||
|
openingBeta=true;flash='';
|
||||||
|
try {
|
||||||
|
const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),servers:[...pickedServers],testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json();
|
||||||
|
if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.');
|
||||||
|
draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true;
|
||||||
|
await goto(`/betas/${body.result.betaId}`);
|
||||||
|
}catch(error){flash=(error as Error).message;}finally{openingBeta=false;}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>New beta | Electric Cherry</title></svelte:head>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="New beta" subtitle="Choose files and test servers, then review before starting.">
|
<SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting.">{#snippet actions()}<Button href="/betas" variant="ghost">Back to betas</Button>{/snippet}</SectionHeader>
|
||||||
{#snippet actions()}<Button href="/betas" variant="ghost">Back to betas</Button>{/snippet}
|
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
|
||||||
</SectionHeader>
|
|
||||||
{#if flash}<div class="ui-error" role="alert">{flash}{#if loading === false && scripts.length === 0}<Button size="sm" onclick={initialize}>Retry</Button>{/if}</div>{/if}
|
|
||||||
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry servers</Button></div>{/if}
|
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry servers</Button></div>{/if}
|
||||||
|
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
|
||||||
{#if loading}<EmptyState title="Loading beta setup…"/>
|
{#if loading}<EmptyState title="Loading beta setup…"/>
|
||||||
{:else if eligibleScripts.length === 0 && !flash}<EmptyState title="No versions ready to test" description="Upload a new version in the Library to start a beta." />
|
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>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.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
|
||||||
|
{:else if !eligibleFiles.length && !flash}<EmptyState title="No files ready to test" description="Upload a file version or finish its active beta, then return here."/><Button href="/scripts">Open Library</Button>
|
||||||
{:else if scripts.length}
|
{:else if scripts.length}
|
||||||
<ol class="steps" aria-label="Beta setup progress">
|
<ol class="steps" aria-label="Beta setup progress">{#each steps as label,index}<li aria-current={step===index?'step':undefined} class:current={step===index}><span>{index+1}</span>{label}</li>{/each}</ol>
|
||||||
{#each steps as label, index}<li aria-current={step === index ? 'step' : undefined} class:current={step === index}><span>{index + 1}</span>{label}</li>{/each}
|
<div class="draft-status">{#if storageUnavailable}<span role="alert">Draft saving is unavailable in this browser. Keep this page open to preserve your work.</span>{:else}<span>Unfinished changes save in this browser.</span>{/if}{#if dirty}<Button variant="ghost" size="sm" onclick={async()=>{if(await confirmAction({title:'Discard beta draft?',message:'Clear the selected files, servers, and custom test plan?',confirmLabel:'Discard draft',danger:true}))discardDraft();}}>Discard draft</Button>{/if}</div>
|
||||||
</ol>
|
<form onsubmit={openBeta} class="beta-form">
|
||||||
<form onsubmit={openNewBeta} class="beta-form">
|
|
||||||
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
|
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
|
||||||
{#if step === 0} <Field label="Files">
|
{#if step===0}
|
||||||
<div class="picks">
|
<label>Beta label (optional)<input bind:value={betaLabel} maxlength="120" placeholder="For example, Town balance update"/><span class="muted">Shown in the beta list so others can identify this test.</span></label>
|
||||||
{#each picks as row, idx (row.key)}
|
<div class="picks">{#each picks as pick,index(pick.key)}<section class="pick-row">
|
||||||
<div class="pick-row">
|
<label>File {index+1}<select bind:value={pick.scriptId} onchange={()=>onPickFile(index)}><option value={null}>Choose a file</option>{#each eligibleFor(index) as file}<option value={file.id}>[{file.kind}] {file.name}</option>{/each}</select></label>
|
||||||
<div class="pick-select">
|
<label>Exact version<select bind:value={pick.versionId} disabled={pick.scriptId===null||loadingVersions.has(pick.key)}><option value={null}>{loadingVersions.has(pick.key)?'Loading versions…':'Choose a version'}</option>{#each versionsFor(pick) as version}<option value={version.id}>{artifactVersionLabel(version)} · {version.sha256.slice(0,8)} · {version.state.replaceAll('-',' ')}</option>{/each}</select></label>
|
||||||
<select
|
<Button ariaLabel={`Remove file ${index+1}`} disabled={picks.length===1} onclick={()=>{picks=picks.filter(p=>p.key!==pick.key);reconcileSelection();}}>Remove</Button>
|
||||||
class="native-select"
|
{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<p class="scope">{scopeText(file)} · {scopeCount(file)} eligible servers · <a href={`/${file.kind==='baseline'?'configs':file.kind+'s'}/${file.id}`}>Inspect file and versions</a>{#if !loadingVersions.has(pick.key)&&!versionsFor(pick).length}<br/>No unpromoted versions. Upload a new version from the file page.{/if}</p>{/if}{/if}
|
||||||
aria-label={`File ${idx + 1}`}
|
</section>{/each}</div>
|
||||||
bind:value={row.scriptId}
|
<Button onclick={()=>picks=[...picks,{key:nextKey++,scriptId:null,versionId:null}]} disabled={picks.length>=eligibleFiles.length}>Add file</Button>
|
||||||
onchange={() => onPickScript(idx)}
|
<p class="muted">Test servers must match every file's scope. Changing a file retains compatible server selections.</p>
|
||||||
>
|
{:else if step===1}
|
||||||
<option value={null}>— Pick an file —</option>
|
<p><strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible servers. Restarts wait for confirmed empty lobbies.</p>
|
||||||
{#each eligibleForRow(idx) as s (s.id)}
|
<details><summary>Selected file scopes</summary><ul>{#each picks as pick}{#if pick.scriptId!==null}{@const file=detailById[pick.scriptId]?.script}{#if file}<li>{file.name}: {scopeText(file)} · {scopeCount(file)} eligible servers</li>{/if}{/if}{/each}</ul></details>
|
||||||
<option value={s.id}>[{s.kind}] {s.name}</option>
|
{#if !candidateServers.length}<div class="ui-error">The selected file scopes have no servers in common. Review the scopes above, then go Back to replace or remove a conflicting file.</div>
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<span class="chev">▾</span>
|
|
||||||
</div>
|
|
||||||
<div class="pick-select">
|
|
||||||
<select
|
|
||||||
class="native-select"
|
|
||||||
aria-label={`Version for file ${idx + 1}`}
|
|
||||||
bind:value={row.versionId}
|
|
||||||
disabled={row.scriptId === null}
|
|
||||||
>
|
|
||||||
{#if row.scriptId === null}
|
|
||||||
<option value={null}>—</option>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#each versionsForRow(row) as v (v.id)}
|
<div class="filters"><label>Find servers<input type="search" bind:value={search} placeholder="Name, map, lobby…"/></label><label>Game<select bind:value={serverGame}><option value="">All games</option>{#each [...new Set(fleetServers.map(s=>s.game).filter(Boolean))] as game}<option value={game}>{game==='t5'?'Black Ops I':game==='t6'?'Black Ops II':game}</option>{/each}</select></label><label>Availability<select bind:value={availability}><option value="">All servers</option><option value="empty">Confirmed empty</option><option value="occupied">Occupied</option><option value="unknown">Unknown / stale / offline</option></select></label></div>
|
||||||
<option value={v.id}>{artifactVersionLabel(v)} · {v.sha256.slice(0, 8)} · {v.state}</option>
|
<div class="actions"><span>{visibleServers.length} visible</span><Button size="sm" onclick={()=>selectVisible(true)} disabled={!visibleServers.length}>Select visible</Button><Button size="sm" onclick={()=>selectVisible(false)} disabled={!visibleServers.some(s=>pickedServers.has(s.name))}>Deselect visible</Button></div>
|
||||||
{/each}
|
{#each groups as group}<fieldset><legend>{fleetIdentity(group.servers[0]).map} · {group.servers[0].game==='t5'?'Black Ops I':'Black Ops II'}</legend><div class="server-picker">{#each group.servers as server}<label class="server-choice"><input type="checkbox" checked={pickedServers.has(server.name)} onchange={()=>toggleServer(server.name)}/><span><ServerIdentity name={server.name}/><small>{freshServer(server,now)&&server.last_players!==null ? `${server.last_players} players · ${server.last_players===0?'Confirmed empty':'Occupied'}` : `Players unknown · ${server.health}`}{#if server.held.length} · In beta #{server.held[0].betaId}{/if}</small></span></label>{/each}</div></fieldset>{/each}
|
||||||
|
{#if !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</select>
|
{:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} serverCount={pickedServers.size}/>
|
||||||
<span class="chev">▾</span>
|
{:else}<section class="ui-panel review"><div class="review-heading"><h3>Files and exact versions</h3><Button size="sm" onclick={()=>move(0)}>Edit files</Button></div><ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}</li>{/each}</ul><div class="review-heading"><h3>Test servers ({pickedServers.size})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div><ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul><div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Rollout and failure handling</h3><p>{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.'}</p><p>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.</p></section>{/if}
|
||||||
</div>
|
<div class="ui-sticky-actions"><p id="beta-forward-help" class="muted">{forwardReason}</p><Button onclick={()=>move(step-1)} disabled={step===0||openingBeta}>Back</Button>{#if step<3}<Button variant="primary" onclick={()=>move(step+1)} disabled={!!forwardReason}>Continue</Button>{:else}<Button type="submit" variant="primary" disabled={!!forwardReason||openingBeta} loading={openingBeta}>Start beta on {pickedServers.size} servers</Button>{/if}</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="row-x"
|
|
||||||
onclick={() => removeRow(idx)}
|
|
||||||
disabled={picks.length === 1}
|
|
||||||
title={picks.length === 1 ? 'Need at least one script' : 'Remove this script'}
|
|
||||||
aria-label="Remove file"
|
|
||||||
>×</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button type="button" class="add-row" onclick={addRow}>+ Add file</button>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{:else if step === 1} <Field label="Test servers" hint="Choose servers that match every selected file.">
|
|
||||||
<div class="server-picker">
|
|
||||||
{#if candidateServers.length === 0}
|
|
||||||
<span class="picker-empty">No server satisfies all selected scopes — pick different scripts.</span>
|
|
||||||
{:else}
|
|
||||||
{#each candidateServers as name (name)}
|
|
||||||
<label class="chk">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={pickedServers.has(name)}
|
|
||||||
onchange={() => toggleServer(name)}
|
|
||||||
/>
|
|
||||||
<ServerIdentity {name} />
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{:else if step === 2}<BetaPlanBuilder bind:plan={testPlan} />
|
|
||||||
{:else}
|
|
||||||
<div class="ui-panel review">
|
|
||||||
<h3>Files and versions</h3>
|
|
||||||
<ul>{#each picks as pick}<li><strong>{scripts.find(s => s.id === pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsForRow(pick).find(v => v.id === pick.versionId))}</li>{/each}</ul>
|
|
||||||
<h3>Test servers ({pickedServers.size})</h3>
|
|
||||||
<ul>{#each [...pickedServers] as name}<li><ServerIdentity {name} /></li>{/each}</ul>
|
|
||||||
<h3>Pass criteria</h3><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour testing window</p>
|
|
||||||
<ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul>
|
|
||||||
<h3>Rollout and failure handling</h3>
|
|
||||||
<p>{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.'}</p>
|
|
||||||
<p class="ui-muted">Starting installs these versions and restarts the selected servers. Promotion to the rest of the fleet is a separate action after testing.</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="ui-sticky-actions">
|
|
||||||
<Button onclick={() => move(step - 1)} disabled={step === 0 || openingBeta}>Back</Button>
|
|
||||||
{#if step < 3}<Button variant="primary" onclick={() => move(step + 1)} disabled={step === 0 ? !filesReady : step === 1 ? pickedServers.size === 0 : testPlan.requirements.length === 0}>Continue</Button>
|
|
||||||
{:else}<Button type="submit" variant="primary" disabled={!canSubmit} loading={openingBeta}>Start beta</Button>{/if}
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
.beta-form input:not([type='checkbox']), .beta-form select { border-color:var(--border-control); }
|
||||||
|
.page,.beta-form { display:grid; gap:24px; } h2,h3,p { margin:0; } h2 { font-size:20px; } h3 { font-size:16px; } p,li { font-size:14px; line-height:1.6; } .muted,.draft-status { color:var(--text-secondary); font-size:13px; }
|
||||||
.beta-form { display: flex; flex-direction: column; gap: var(--space-5); }
|
.steps { display:flex; list-style:none; gap:16px; padding:0; margin:0; flex-wrap:wrap; } .steps li { display:flex; align-items:center; gap:8px; color:var(--text-secondary); } .steps li span { border:1px solid var(--border-strong); border-radius:50%; padding:3px 9px; } .steps .current { color:var(--text-primary); font-weight:600; } .steps .current span { background:var(--accent-primary); color:var(--text-on-accent); }
|
||||||
|
.picks { display:grid; gap:16px; } .pick-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end; padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } .scope { grid-column:1/-1; font-size:13px; color:var(--text-secondary); }
|
||||||
.picks { display: flex; flex-direction: column; gap: var(--space-2); }
|
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); min-width:0; } input:not([type='checkbox']),select { width:100%; min-height:40px; padding:9px 12px; box-sizing:border-box; border:1px solid var(--border-strong); border-radius:6px; color:var(--text-primary); background:var(--surface-sunken); font:inherit; } a,.text-action { color:var(--accent-primary-hover); } .text-action { background:none; border:0; text-decoration:underline; cursor:pointer; }
|
||||||
.pick-row {
|
.filters,.actions,.draft-status,.review-heading { display:flex; align-items:center; flex-wrap:wrap; gap:12px; } .filters { align-items:end; } .filters label { flex:1 1 180px; } .draft-status,.review-heading { justify-content:space-between; } .draft { display:grid; gap:16px; }
|
||||||
display: grid;
|
fieldset { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; min-width:0; } legend { font-size:14px; padding:0 8px; } .server-picker { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr)); gap:12px; } .server-choice { display:flex; align-items:center; min-height:48px; gap:10px; color:var(--text-primary); cursor:pointer; } .server-choice small { display:block; color:var(--text-secondary); font-size:12px; margin-top:4px; } .server-choice input { accent-color:var(--accent-primary); width:18px; height:18px; flex-shrink:0; }
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
.review { display:grid; gap:16px; } .review ul { margin:0; } .review li { margin-bottom:8px; } .notice { padding:12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); } .ui-sticky-actions p { margin-right:auto; flex:1 1 200px; }
|
||||||
gap: var(--space-2);
|
@media(max-width:700px) { .pick-row { grid-template-columns:minmax(0,1fr) auto; } .pick-row label:first-child { grid-column:1/-1; } .steps { gap:10px; } .steps li { font-size:12px; } }
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.pick-select { position: relative; min-width: 0; }
|
|
||||||
.native-select {
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
padding: 9px 32px 9px 12px;
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color var(--dur-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.native-select:hover { border-color: var(--border-strong); }
|
|
||||||
.native-select:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(46, 134, 209, 0.15); }
|
|
||||||
.native-select:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
.chev {
|
|
||||||
position: absolute;
|
|
||||||
right: var(--space-3);
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
pointer-events: none;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.row-x {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 0 14px;
|
|
||||||
height: 38px;
|
|
||||||
font-size: var(--text-lg);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.row-x:hover:not(:disabled) { color: var(--state-danger); border-color: rgba(255, 86, 112, 0.4); }
|
|
||||||
.row-x:disabled { opacity: 0.3; cursor: default; }
|
|
||||||
|
|
||||||
.add-row {
|
|
||||||
align-self: flex-start;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px dashed var(--border-strong);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 8px 14px;
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.add-row:hover { color: var(--text-primary); border-color: var(--accent-primary); }
|
|
||||||
|
|
||||||
.server-picker {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-3);
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
.picker-empty {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.chk {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.chk input { accent-color: var(--accent-primary); cursor: pointer; }
|
|
||||||
|
|
||||||
h2 { margin: 0; font-size: var(--text-lg); }
|
|
||||||
.steps { display: flex; list-style: none; margin: 0; padding: 0; gap: 12px; flex-wrap: wrap; }
|
|
||||||
.steps li { display: flex; align-items: center; gap: 8px; color: var(--text-secondary); font-size: 13px; }
|
|
||||||
.steps li span { border: 1px solid var(--border-subtle); border-radius: 50%; padding: 4px 8px; }
|
|
||||||
.steps li.current { color: var(--text-primary); font-weight: 600; }
|
|
||||||
.steps li.current span { background: var(--accent-primary); color: var(--text-on-accent); }
|
|
||||||
.review { display: grid; gap: 16px; }
|
|
||||||
.review h3,.review p,.review ul { margin: 0; }
|
|
||||||
.review li { margin-bottom: 8px; }
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.pick-row { grid-template-columns: minmax(0,1fr) 44px; }
|
|
||||||
.pick-select:first-child { grid-column: 1 / -1; }
|
|
||||||
.chk { min-height: 44px; }
|
|
||||||
.server-picker { flex-direction: column; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
|
import type { DriftPlan, DriftResult } from '$lib/drift-plan.js';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
@@ -51,7 +55,7 @@
|
|||||||
driftKind: 'untracked' | 'missing' | 'replace';
|
driftKind: 'untracked' | 'missing' | 'replace';
|
||||||
}
|
}
|
||||||
interface DriftServer { server: string; files: DriftFile[] }
|
interface DriftServer { server: string; files: DriftFile[] }
|
||||||
interface DriftPreview { totalDrift: number; servers: DriftServer[] }
|
interface DriftPreview { totalDrift: number; servers: DriftServer[]; scannedAt: string }
|
||||||
|
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
@@ -64,10 +68,27 @@
|
|||||||
let running = $state(false);
|
let running = $state(false);
|
||||||
let rollingBack = $state<string | null>(null);
|
let rollingBack = $state<string | null>(null);
|
||||||
let flash = $state<string | null>(null);
|
let flash = $state<string | null>(null);
|
||||||
|
let flashError = $state(false);
|
||||||
|
let flashId = $state('');
|
||||||
|
let selected = $state<string[]>([]);
|
||||||
|
let reviewOpen = $state(false);
|
||||||
|
let plan = $state<DriftPlan | null>(null);
|
||||||
|
let planError = $state('');
|
||||||
|
let reviewing = $state(false);
|
||||||
|
let reviewAll = $state(false);
|
||||||
|
let result = $state<DriftResult | null>(null);
|
||||||
|
const skipped = $derived(result?.perServer.filter(server => server.skipped.length) ?? []);
|
||||||
let driftError = $state('');
|
let driftError = $state('');
|
||||||
// Drift panel opens automatically when there's a problem; collapses when clean.
|
// Drift panel opens automatically when there's a problem; collapses when clean.
|
||||||
let driftOpen = $state(false);
|
let driftOpen = $state(false);
|
||||||
let driftFilter = $state('');
|
let driftFilter = $state('');
|
||||||
|
$effect(() => { driftFilter = $page.url.searchParams.get('server') ?? ''; limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
|
||||||
|
function saveContext() {
|
||||||
|
const url = new URL($page.url);
|
||||||
|
if (driftFilter) url.searchParams.set('server', driftFilter); else url.searchParams.delete('server');
|
||||||
|
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
|
||||||
|
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCampaigns(): Promise<void> {
|
async function loadCampaigns(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +105,7 @@
|
|||||||
const res = await fetch('/api/drift');
|
const res = await fetch('/api/drift');
|
||||||
if (!res.ok) throw new Error('Could not scan drift.');
|
if (!res.ok) throw new Error('Could not scan drift.');
|
||||||
drift = (await res.json()) as DriftPreview;
|
drift = (await res.json()) as DriftPreview;
|
||||||
|
selected = selected.filter(name => drift!.servers.some(server => server.server === name));
|
||||||
driftError = '';
|
driftError = '';
|
||||||
} catch (error) { driftError = (error as Error).message;
|
} catch (error) { driftError = (error as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -109,33 +131,31 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function reviewDrift(all: boolean, targets = selected): Promise<void> {
|
||||||
|
reviewOpen = true; reviewing = true; plan = null; planError = ''; reviewAll = all;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/drift', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dryRun: true, servers: all ? undefined : targets }) });
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok) throw new Error(body.error ?? 'Could not prepare the reconciliation plan.');
|
||||||
|
plan = body.plan;
|
||||||
|
} catch (error) { planError = (error as Error).message; }
|
||||||
|
finally { reviewing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
async function runDrift(): Promise<void> {
|
async function runDrift(): Promise<void> {
|
||||||
if (!drift || drift.totalDrift === 0) return;
|
if (!plan || running) return;
|
||||||
const parts: string[] = [];
|
running = true; planError = ''; result = null;
|
||||||
if (driftCounts.untracked > 0) parts.push(`delete ${driftCounts.untracked} untracked file(s)`);
|
|
||||||
if (driftCounts.missing > 0) parts.push(`restore ${driftCounts.missing} missing artifact(s)`);
|
|
||||||
if (driftCounts.replace > 0) parts.push(`replace ${driftCounts.replace} existing artifact(s)`);
|
|
||||||
if (!await confirmAction({ title:'Reconcile drift?', message:`This will ${parts.join(' and ')} across ${drift.servers.length} servers.`, confirmLabel:'Reconcile', danger:true })) return;
|
|
||||||
running = true; flash = null;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/drift', {
|
const res = await fetch('/api/drift', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({ servers: reviewAll ? undefined : plan.servers, expectedFingerprint: plan.fingerprint })
|
||||||
});
|
});
|
||||||
const body = (await res.json()) as {
|
const body = await res.json();
|
||||||
ok: boolean; error?: string;
|
if (!res.ok || !body.ok) { if (body.plan) plan = body.plan; throw new Error(body.error ?? 'Reconciliation failed.'); }
|
||||||
result?: { deploymentId: string | null; totalRemoved: number; totalRestored?: number; totalServers: number };
|
result = body.result; reviewOpen = false;
|
||||||
};
|
|
||||||
if (!body.ok) { flash = body.error ?? 'drift run failed'; return; }
|
|
||||||
const removed = body.result?.totalRemoved ?? 0;
|
|
||||||
const restored = body.result?.totalRestored ?? 0;
|
|
||||||
const parts2: string[] = [];
|
|
||||||
if (removed > 0) parts2.push(`removed ${removed}`);
|
|
||||||
if (restored > 0) parts2.push(`installed ${restored}`);
|
|
||||||
flash = `${parts2.join(', ') || 'no'} file(s) across ${body.result?.totalServers ?? 0} server(s)`;
|
|
||||||
await Promise.all([loadCampaigns(), scanDrift()]);
|
await Promise.all([loadCampaigns(), scanDrift()]);
|
||||||
} catch (error) { flash = (error as Error).message; } finally {
|
} catch (error) { planError = (error as Error).message; } finally {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,14 +194,14 @@
|
|||||||
const summary = scriptSummary(d) || 'this promote';
|
const summary = scriptSummary(d) || 'this promote';
|
||||||
if (!await confirmAction({ title:'Roll back promotion?', message:`Restore the previous version of ${summary} on ${d.servers.length} servers.`, confirmLabel:'Roll back', danger:true })) return;
|
if (!await confirmAction({ title:'Roll back promotion?', message:`Restore the previous version of ${summary} on ${d.servers.length} servers.`, confirmLabel:'Roll back', danger:true })) return;
|
||||||
rollingBack = d.id;
|
rollingBack = d.id;
|
||||||
flash = null;
|
flash = null; flashId = d.id; flashError = false;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' });
|
const res = await fetch(`/api/campaigns/${d.id}/rollback`, { method: 'POST' });
|
||||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||||
if (!body.ok) { flash = body.error ?? 'rollback failed'; return; }
|
if (!res.ok || !body.ok) { flashError = true; flash = body.error ?? 'Rollback failed'; return; }
|
||||||
flash = 'Rollback started';
|
flash = 'Rollback started';
|
||||||
await loadCampaigns();
|
await loadCampaigns();
|
||||||
} catch (error) { flash = (error as Error).message; } finally {
|
} catch (error) { flashError = true; flash = (error as Error).message; } finally {
|
||||||
rollingBack = null;
|
rollingBack = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +225,8 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Rollouts | Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rollouts"
|
title="Rollouts"
|
||||||
@@ -249,28 +271,32 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={runDrift}
|
onclick={() => reviewDrift(false)}
|
||||||
disabled={running || scanning || !drift || drift.totalDrift === 0}
|
disabled={running || scanning || reviewing || !selected.length}
|
||||||
loading={running}
|
loading={running}
|
||||||
>
|
>
|
||||||
Reconcile
|
Review {selected.length} selected
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onclick={() => reviewDrift(true)} disabled={running || scanning || reviewing || !drift?.totalDrift}>Review entire fleet</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if driftOpen}
|
{#if driftOpen}
|
||||||
<div class="drift-body" id="drift-body">
|
<div class="drift-body" id="drift-body">
|
||||||
|
{#if drift}<p class="scan-context">Scanned {new Date(drift.scannedAt).toLocaleString()}. Selection controls the action scope; search only changes the visible list.</p>{/if}
|
||||||
{#if drift && drift.totalDrift > 0}
|
{#if drift && drift.totalDrift > 0}
|
||||||
<input
|
<input
|
||||||
class="drift-filter" aria-label="Filter drift by server"
|
class="drift-filter" aria-label="Filter drift by server"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Filter by server name…"
|
placeholder="Filter by server name…"
|
||||||
bind:value={driftFilter}
|
bind:value={driftFilter}
|
||||||
|
oninput={event => { driftFilter = event.currentTarget.value; saveContext(); }}
|
||||||
/>
|
/>
|
||||||
|
<div class="selection-actions"><Button size="sm" onclick={() => selected = [...new Set([...selected, ...filteredDriftServers.map(server => server.server)])]}>Select visible</Button><Button size="sm" variant="ghost" disabled={!selected.length} onclick={() => selected = []}>Clear selection</Button><span>{selected.length} selected</span></div>
|
||||||
{#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
|
{#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
|
||||||
<div class="drift-server">
|
<div class="drift-server">
|
||||||
<div class="drift-server-head">
|
<div class="drift-server-head">
|
||||||
<span class="server-name"><ServerIdentity name={s.server} /></span>
|
<label class="server-name"><input type="checkbox" checked={selected.includes(s.server)} onchange={event => selected = event.currentTarget.checked ? [...selected, s.server] : selected.filter(name => name !== s.server)} /> <ServerIdentity name={s.server} /></label>
|
||||||
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
|
<span class="server-count">{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="drift-files">
|
<div class="drift-files">
|
||||||
@@ -296,9 +322,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if flash}
|
{#if result}<div class="result" class:result-error={skipped.length > 0} role={skipped.length ? 'alert' : 'status'}>
|
||||||
<div class="flash">{flash}</div>
|
<strong>{result.totalServers} server{result.totalServers === 1 ? '' : 's'} updated{skipped.length ? `, ${skipped.length} with skipped files` : ''}</strong>
|
||||||
{/if}
|
<p>{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.'}</p>
|
||||||
|
{#if result.deploymentId}<a href="/campaigns/{result.deploymentId}">View rollout →</a>{/if}
|
||||||
|
{#each skipped as server}<div><a href="/servers/{encodeURIComponent(server.server)}?tab=diagnostics"><ServerIdentity name={server.server} /></a><ul>{#each server.skipped as reason}<li>{reason}</li>{/each}</ul></div>{/each}
|
||||||
|
{#if skipped.length}<Button onclick={() => reviewDrift(false, skipped.map(server => server.server))}>Review unresolved servers</Button>{/if}
|
||||||
|
</div>{/if}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Active pipelines -->
|
<!-- Active pipelines -->
|
||||||
@@ -345,12 +375,25 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
<ListFooter total={history.length} bind:limit label="rollouts" />
|
<ListFooter total={history.length} bind:limit onchange={saveContext} label="rollouts" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog bind:open={reviewOpen} title="Review reconciliation" busy={running || reviewing} wide>
|
||||||
|
{#if planError}<p class="ui-error" role="alert">{planError}</p>{/if}
|
||||||
|
{#if reviewing}<p role="status">Comparing current files with their intended versions…</p>
|
||||||
|
{:else if plan}
|
||||||
|
<p><strong>{reviewAll ? 'Entire fleet' : 'Selected servers'}: {plan.servers.length} server{plan.servers.length === 1 ? '' : 's'}, {plan.files.length} file change{plan.files.length === 1 ? '' : 's'}.</strong></p>
|
||||||
|
<p>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.</p>
|
||||||
|
<p class="scan-context">Reviewed {new Date(plan.scannedAt).toLocaleString()}. Changes to this plan require another review.</p>
|
||||||
|
<div class="plan-files">{#each plan.servers as server}{@const files = plan.files.filter(file => file.server === server)}{#if files.length}<section><h3><ServerIdentity name={server} /></h3>{#each files as file}<div class="plan-file"><strong>{file.driftKind === 'untracked' ? 'Remove' : file.driftKind === 'missing' ? 'Restore' : 'Replace'} <code>{file.relPath}</code></strong><span>{file.beforeSize === null ? 'Absent' : `${file.beforeSize} bytes`} → {file.afterSize === null ? 'Removed' : `${file.afterSize} bytes`}</span><details><summary>File fingerprints</summary><p>Before: {file.beforeSha256 ?? 'Absent'}<br />After: {file.afterSha256 ?? 'Removed'}</p></details></div>{/each}</section>{/if}{/each}</div>
|
||||||
|
{/if}
|
||||||
|
{#snippet actions()}<Button variant="ghost" disabled={running || reviewing} onclick={() => reviewOpen = false}>Cancel</Button><Button variant="danger" disabled={running || reviewing || !plan?.files.length} loading={running} onclick={runDrift}>Apply {plan?.files.length ?? 0} reviewed changes</Button>{/snippet}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{#snippet deployRow(d: Deployment)}
|
{#snippet deployRow(d: Deployment)}
|
||||||
<article class="deploy-row">
|
<article class="deploy-row">
|
||||||
|
{#if flash && flashId === d.id}<div class="result" class:result-error={flashError} role={flashError ? 'alert' : 'status'}>{flash}</div>{/if}
|
||||||
<div class="row-head">
|
<div class="row-head">
|
||||||
<div class="row-head-l">
|
<div class="row-head-l">
|
||||||
<a class="action" href="/campaigns/{d.id}">{d.action} rollout <span class="sr-only">{d.id}</span></a>
|
<a class="action" href="/campaigns/{d.id}">{d.action} rollout <span class="sr-only">{d.id}</span></a>
|
||||||
@@ -384,7 +427,7 @@
|
|||||||
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
|
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
|
||||||
>
|
>
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span class="node-label">{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}</span>
|
<a class="node-label" href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''}</a><StatusBadge state={s.restart_state} />
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -403,6 +446,16 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.scan-context { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||||
|
.selection-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-block: 12px; }
|
||||||
|
.result { padding: 16px; margin-top: 16px; border: 1px solid var(--state-online); border-radius: var(--radius-md); line-height: 1.6; }
|
||||||
|
.result-error { border-color: var(--state-warning); }
|
||||||
|
.result p { margin-block: 8px; }
|
||||||
|
.result a { color: var(--accent-primary-hover); }
|
||||||
|
.plan-files { display: grid; gap: 20px; }
|
||||||
|
.plan-file { display: grid; gap: 8px; padding-block: 12px; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||||
|
.plan-file span, .plan-file details { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.server-name { display: flex; align-items: center; gap: 10px; }
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -505,15 +558,6 @@
|
|||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash {
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
background: rgba(124, 212, 255, 0.06);
|
|
||||||
border: 1px solid rgba(124, 212, 255, 0.22);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--state-online);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sections */
|
/* Sections */
|
||||||
.section {
|
.section {
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
let diffLoading = $state(false);
|
let diffLoading = $state(false);
|
||||||
let diffRequest = 0;
|
let diffRequest = 0;
|
||||||
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
|
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
|
||||||
|
let retryFile = $state<FileRow | null>(null);
|
||||||
|
const completed = $derived(detail?.servers.filter(server => server.restart_state === 'done').length ?? 0);
|
||||||
|
const unresolved = $derived(detail?.servers.filter(server => server.restart_state !== 'done').length ?? 0);
|
||||||
|
const rankedServers = $derived([...(detail?.servers ?? [])].sort((a, b) => {
|
||||||
|
const rank: Record<string, number> = { failed: 0, manual: 1, held: 2, pending: 3, done: 4 };
|
||||||
|
return (rank[a.restart_state] ?? 3) - (rank[b.restart_state] ?? 3);
|
||||||
|
}));
|
||||||
|
|
||||||
async function loadDetail(): Promise<void> {
|
async function loadDetail(): Promise<void> {
|
||||||
const id = $page.params.id;
|
const id = $page.params.id;
|
||||||
@@ -64,6 +71,7 @@
|
|||||||
onMount(() => pollVisible(loadDetail, 5000, false));
|
onMount(() => pollVisible(loadDetail, 5000, false));
|
||||||
|
|
||||||
async function loadDiff(f: FileRow) {
|
async function loadDiff(f: FileRow) {
|
||||||
|
retryFile = f;
|
||||||
const id = $page.params.id;
|
const id = $page.params.id;
|
||||||
const current = ++diffRequest;
|
const current = ++diffRequest;
|
||||||
diffOpen = true; diffLoading = true; diffError = ''; openFile = null;
|
diffOpen = true; diffLoading = true; diffError = ''; openFile = null;
|
||||||
@@ -101,13 +109,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Rollout | Electric Cherry</title></svelte:head>
|
<svelte:head><title>{detail ? `${detail.deployment.action} rollout · ${detail.deployment.id}` : 'Rollout'} | Electric Cherry</title></svelte:head>
|
||||||
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
|
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
|
||||||
|
|
||||||
{#if detail}
|
{#if detail}
|
||||||
{@const action = detail.deployment.action}
|
{@const action = detail.deployment.action}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title={action} subtitle={detail.deployment.id}>
|
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} rollout`} subtitle={detail.deployment.id}>
|
||||||
{#snippet crumbs()}
|
{#snippet crumbs()}
|
||||||
<Breadcrumbs items={[
|
<Breadcrumbs items={[
|
||||||
{ label: 'Rollouts', href: '/campaigns' },
|
{ label: 'Rollouts', href: '/campaigns' },
|
||||||
@@ -116,6 +124,8 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
|
||||||
|
<section class="outcome" aria-label="Rollout outcome"><h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}</h2><p>Files are staged before activation. Servers restart when empty, then their health is verified.</p>{#if unresolved}<p>Review failed or waiting servers below. Diagnose connectivity or inspect startup logs from the affected row.</p>{:else}<p>Every recorded server completed activation.</p>{/if}</section>
|
||||||
|
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div class="meta-grid">
|
<div class="meta-grid">
|
||||||
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
|
<div class="kv"><span class="k">Started</span><span class="v mono">{fmt(detail.deployment.started_at)}</span></div>
|
||||||
@@ -125,19 +135,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<OperationHistory
|
|
||||||
steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))}
|
|
||||||
events={detail.events}
|
|
||||||
active={detail.servers.some(s => s.restart_state === 'pending' || s.restart_state === 'failed')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>Servers</h2>
|
<h2>Servers</h2>
|
||||||
<Badge variant="neutral" size="sm">{detail.servers.length}</Badge>
|
<Badge variant="neutral" size="sm">{detail.servers.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="server-list">
|
<div class="server-list">
|
||||||
{#each detail.servers as s (s.server)}
|
{#each rankedServers as s (s.server)}
|
||||||
<div class="srv-row">
|
<div class="srv-row">
|
||||||
<div class="srv-l">
|
<div class="srv-l">
|
||||||
<a class="srv-name" href="/servers/{s.server}"><ServerIdentity name={s.server} /></a>
|
<a class="srv-name" href="/servers/{s.server}"><ServerIdentity name={s.server} /></a>
|
||||||
@@ -148,12 +152,13 @@
|
|||||||
<div class="srv-r">
|
<div class="srv-r">
|
||||||
<StatusBadge state={s.restart_state} />
|
<StatusBadge state={s.restart_state} />
|
||||||
{#if s.players !== null && s.players > 0}
|
{#if s.players !== null && s.players > 0}
|
||||||
<span class="players">{s.players}p</span>
|
<span class="players">{s.players} players at last check</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="attempts">{s.attempts} attempt{s.attempts === 1 ? '' : 's'}</span>
|
<span class="attempts">{s.attempts} attempt{s.attempts === 1 ? '' : 's'}</span>
|
||||||
{#if s.restarted_at}
|
{#if s.restarted_at}
|
||||||
<span class="when">restarted {fmt(s.restarted_at)}</span>
|
<span class="when">restarted {fmt(s.restarted_at)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if s.restart_state !== 'done'}<a href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">Diagnose</a><a href="/servers/{encodeURIComponent(s.server)}?tab=logs">Open logs</a>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -189,8 +194,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<OperationHistory steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))} events={detail.events} active={detail.servers.some(s => s.restart_state === 'pending' || s.restart_state === 'failed')} />
|
||||||
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
|
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
|
||||||
{#if diffError}<p class="ui-error" role="alert">{diffError}</p>
|
{#if diffError}<p class="ui-error" role="alert">{diffError}</p><Button onclick={() => { if (retryFile) void loadDiff(retryFile); }}>Retry comparison</Button>
|
||||||
{:else if diffLoading}<EmptyState title="Loading comparison…" />
|
{:else if diffLoading}<EmptyState title="Loading comparison…" />
|
||||||
{:else if openFile}<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />{/if}
|
{:else if openFile}<DiffViewer before={openFile.before} after={openFile.after} verb={openFile.verb} relPath={openFile.relPath} />{/if}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -200,6 +206,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.outcome { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); }.outcome h2 { font-size: 20px; margin: 0; }.outcome p { color: var(--text-secondary); line-height: 1.6; margin: 10px 0 0; }.srv-r a { color: var(--accent-primary-hover); font-size: 13px; }
|
||||||
.page {
|
.page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||||
@@ -19,9 +21,10 @@
|
|||||||
|
|
||||||
interface Deployment {
|
interface Deployment {
|
||||||
id: string; server: string; action: 'create' | 'destroy';
|
id: string; server: string; action: 'create' | 'destroy';
|
||||||
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted';
|
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted' | 'cancelled';
|
||||||
stage: string; started_at: string; finished_at: string | null;
|
stage: string; started_at: string; finished_at: string | null;
|
||||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||||
|
canCancelRemoval: boolean;
|
||||||
}
|
}
|
||||||
let deployments = $state<Deployment[]>([]);
|
let deployments = $state<Deployment[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -42,7 +45,34 @@
|
|||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
let collectionLimit = $state(10);
|
let collectionLimit = $state(10);
|
||||||
let historyState = $state('all');
|
let historyState = $state('all');
|
||||||
$effect(() => { action; search; tab; historyState; limit = 25; collectionLimit = 10; });
|
let cancelling = $state(false);
|
||||||
|
let cancellationNotice = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
const params = $page.url.searchParams;
|
||||||
|
tab = ['active', 'blueprints', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'active';
|
||||||
|
action = ['create', 'destroy'].includes(params.get('action') ?? '') ? params.get('action')! : 'all';
|
||||||
|
historyState = params.get('status') === 'attention' ? 'attention' : 'all';
|
||||||
|
search = params.get('server') ?? '';
|
||||||
|
limit = Math.max(25, Number(params.get('limit')) || 25);
|
||||||
|
collectionLimit = Math.max(10, Number(params.get('collections')) || 10);
|
||||||
|
});
|
||||||
|
function saveContext(reset = false) {
|
||||||
|
if (reset) { limit = 25; collectionLimit = 10; }
|
||||||
|
const url = new URL($page.url);
|
||||||
|
for (const [key, value] of Object.entries({ view: tab === 'active' ? '' : tab, action: action === 'all' ? '' : action, status: historyState === 'all' ? '' : historyState, server: search, limit: limit > 25 ? String(limit) : '', collections: collectionLimit > 10 ? String(collectionLimit) : '' })) {
|
||||||
|
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
|
}
|
||||||
|
async function cancelRequests(ids: string[]) {
|
||||||
|
cancelling = true; cancellationNotice = ''; let cancelled = 0; const unavailable: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
try { const response = await fetch(`/api/deployments/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); if (response.ok) cancelled++; else unavailable.push(id); }
|
||||||
|
catch { unavailable.push(id); }
|
||||||
|
}
|
||||||
|
cancellationNotice = `${cancelled} queued removal${cancelled === 1 ? '' : 's'} cancelled.${cancelled ? ' The servers covered by those cancelled requests and their files were kept.' : ''}${unavailable.length ? ` ${unavailable.length} could not be cancelled; refresh their operations to see whether removal has begun.` : ''}`;
|
||||||
|
cancelling = false; await load();
|
||||||
|
}
|
||||||
const visibleCollections = $derived(collections.filter(c => tab === 'active' ? c.active > 0 : c.active === 0));
|
const visibleCollections = $derived(collections.filter(c => tab === 'active' ? c.active > 0 : c.active === 0));
|
||||||
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'blueprints',label:'Blueprints',badge:blueprintsLoading ? null : blueprints.length},{id:'history',label:'History'}]);
|
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'blueprints',label:'Blueprints',badge:blueprintsLoading ? null : blueprints.length},{id:'history',label:'History'}]);
|
||||||
$effect(() => { if (tab === 'blueprints') void loadBlueprints(); });
|
$effect(() => { if (tab === 'blueprints') void loadBlueprints(); });
|
||||||
@@ -93,9 +123,6 @@
|
|||||||
}
|
}
|
||||||
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
function fmt(value: string) { return new Date(value).toLocaleString(); }
|
||||||
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
function warnings(d: Deployment): string[] { try { return JSON.parse(d.warnings); } catch { return []; } }
|
||||||
function status(d: Deployment) {
|
|
||||||
return { queued: 'Queued', 'awaiting-health': 'Verifying health', waiting: 'Waiting for empty', running: 'In flight', done: 'Completed', failed: 'Failed', warning: 'Completed with warnings', interrupted: 'Interrupted' }[d.state];
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
<svelte:head><title>Server deployments | Electric Cherry</title></svelte:head>
|
||||||
@@ -103,12 +130,13 @@
|
|||||||
<SectionHeader title="Server deployments" subtitle="Create servers, apply blueprints, and track fleet changes.">
|
<SectionHeader title="Server deployments" subtitle="Create servers, apply blueprints, and track fleet changes.">
|
||||||
{#snippet actions()}<Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet}
|
{#snippet actions()}<Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<Tabs {tabs} bind:value={tab} ariaLabel="Deployment views" idPrefix="deployment-tabs" panelId="deployment-panel" />
|
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Deployment views" idPrefix="deployment-tabs" panelId="deployment-panel" />
|
||||||
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
|
<DeployServerDialog bind:open onblueprint={loadBlueprints} />
|
||||||
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
<BlueprintBuilder bind:open={builderOpen} blueprint={editingBlueprint} onsaved={blueprintSaved} />
|
||||||
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
<DeployCollectionDialog bind:open={collectionOpen} {blueprintId} />
|
||||||
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; void load(); }} />
|
<HousePartyDialog bind:open={housePartyOpen} onqueued={count => { housePartyNotice = `Destruction queued for ${count} server${count === 1 ? '' : 's'}. Follow progress in the Active tab.`; tab = 'active'; void load(); }} />
|
||||||
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
|
{#if housePartyNotice}<p role="status">{housePartyNotice}</p>{/if}
|
||||||
|
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
|
||||||
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
|
<div id="deployment-panel" role="tabpanel" aria-labelledby={"deployment-tabs-" + tab} class="panels">
|
||||||
{#if tab === 'blueprints'}
|
{#if tab === 'blueprints'}
|
||||||
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint</Button></div>
|
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create blueprint</Button></div>
|
||||||
@@ -129,7 +157,7 @@
|
|||||||
<p>{blueprint.description || 'Your target lineup. Only missing servers will be created.'}</p>
|
<p>{blueprint.description || 'Your target lineup. Only missing servers will be created.'}</p>
|
||||||
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul>
|
<ul class="blueprint-lineup">{#each blueprint.entries as entry}<li><strong>{entry.count}×</strong> {entry.mapName} <small>{entry.game.toUpperCase()}</small></li>{/each}</ul>
|
||||||
<div class="blueprint-footer">
|
<div class="blueprint-footer">
|
||||||
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Conform fleet →</Button>
|
<Button onclick={() => { blueprintId = blueprint.id; collectionOpen = true; }}>Preview missing servers →</Button>
|
||||||
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
|
<button class="edit" onclick={() => { editingBlueprint = blueprint; builderOpen = true; }}>Edit</button>
|
||||||
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
|
<button class="remove" disabled={!!removing} onclick={() => removeBlueprint(blueprint.id)}>Remove</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +166,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
<ListFooter total={blueprints.length} bind:limit label="blueprints" />
|
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="blueprints" />
|
||||||
{:else}
|
{:else}
|
||||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||||
{#if visibleCollections.length}
|
{#if visibleCollections.length}
|
||||||
@@ -152,11 +180,12 @@
|
|||||||
<div class="collection-footer"><span class="meta">Queued {fmt(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow launch' : 'View results'} →</span></div>
|
<div class="collection-footer"><span class="meta">Queued {fmt(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow launch' : 'View results'} →</span></div>
|
||||||
</a>
|
</a>
|
||||||
{/each}</div>
|
{/each}</div>
|
||||||
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} step={10} label="collections" />
|
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} onchange={() => saveContext()} step={10} label="collections" />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tab === 'active'}
|
{#if tab === 'active'}
|
||||||
{#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; }}>Review history</Button></div>{/if}
|
{#if attention.length}<div class="ui-summary"><span>{attention.length} recorded operations need review</span><Button size="sm" onclick={() => { historyState = 'attention'; tab = 'history'; saveContext(true); }}>Review history</Button></div>{/if}
|
||||||
|
{#if active.some(d => d.canCancelRemoval)}<div class="ui-summary"><span>Waiting removals can be withdrawn until removal begins. Running and completed removals cannot be reversed.</span><Button disabled={cancelling} onclick={() => cancelRequests(active.filter(d => d.canCancelRemoval).map(d => d.id))}>Cancel all waiting removals</Button></div>{/if}
|
||||||
<section>
|
<section>
|
||||||
<h2>In flight <span>{active.length}</span></h2>
|
<h2>In flight <span>{active.length}</span></h2>
|
||||||
{#if loading}<p>Loading deployments...</p>
|
{#if loading}<p>Loading deployments...</p>
|
||||||
@@ -167,13 +196,13 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>History</h2>
|
<h2>History</h2>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<label>Operation <select bind:value={action}><option value="all">All operations</option><option value="create">Creations</option><option value="destroy">Destructions</option></select></label>
|
<label>Operation <select bind:value={action} onchange={event => { action = event.currentTarget.value; saveContext(true); }}><option value="all">All operations</option><option value="create">Creations</option><option value="destroy">Removals</option></select></label>
|
||||||
<label>Status <select bind:value={historyState}><option value="all">All statuses</option><option value="attention">Needs review</option></select></label>
|
<label>Status <select bind:value={historyState} onchange={event => { historyState = event.currentTarget.value; saveContext(true); }}><option value="all">All statuses</option><option value="attention">Needs review</option></select></label>
|
||||||
<label>Server <input bind:value={search} placeholder="Filter by server name" /></label>
|
<label>Server <input bind:value={search} oninput={event => { search = event.currentTarget.value; saveContext(true); }} placeholder="Filter by server name" /></label>
|
||||||
</div>
|
</div>
|
||||||
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
|
{#if !loading && history.length === 0}<EmptyState title="No matching operations" description="Try another search or operation type." />
|
||||||
{:else}<div class="rows">{#each history.slice(0,limit) as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
{:else}<div class="rows">{#each history.slice(0,limit) as d (d.id)}{@render row(d)}{/each}</div>{/if}
|
||||||
<ListFooter total={history.length} bind:limit label="operations" />
|
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="operations" />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -181,7 +210,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#snippet row(d: Deployment)}
|
{#snippet row(d: Deployment)}
|
||||||
<a class="deployment-row" href="/deployments/{d.id}">
|
<article class="deployment-row"><a class="row-link" href="/deployments/{d.id}">
|
||||||
<div class="row-head">
|
<div class="row-head">
|
||||||
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
|
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{d.action === 'create' ? 'Creation' : 'Destruction'}</Badge>
|
||||||
<strong class="server"><ServerIdentity name={d.server} /></strong>
|
<strong class="server"><ServerIdentity name={d.server} /></strong>
|
||||||
@@ -192,10 +221,11 @@
|
|||||||
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
|
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
|
||||||
{#if d.error}<p class="error">{d.error}</p>{/if}
|
{#if d.error}<p class="error">{d.error}</p>{/if}
|
||||||
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
|
||||||
</a>
|
</a>{#if d.canCancelRemoval}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel queued removal</Button></div>{/if}</article>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.row-link { color: inherit; text-decoration: none; display: block; }.row-actions { margin-top: 12px; }
|
||||||
|
|
||||||
.page { display: flex; flex-direction: column; gap: 28px; }
|
.page { display: flex; flex-direction: column; gap: 28px; }
|
||||||
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
.row-head, .filters, .meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
stage: string; started_at: string; finished_at: string | null;
|
stage: string; started_at: string; finished_at: string | null;
|
||||||
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
triggered_by: string; error: string | null; warnings: string; server_exists: number;
|
||||||
}
|
}
|
||||||
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean; canResumeCleanup: boolean; collection: { id: string; name: string } | null }
|
interface Detail { deployment: Deployment; events: { id: number; created_at: string; message: string }[]; recipe: LaunchRecipeSummary | null; blueprintName: string | null; canResumeVerification: boolean; canResumeCleanup: boolean; canCancelRemoval: boolean; collection: { id: string; name: string } | null }
|
||||||
let detail = $state<Detail | null>(null);
|
let detail = $state<Detail | null>(null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let refreshing = false;
|
let refreshing = false;
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
});
|
});
|
||||||
let explanation = $derived.by(() => {
|
let explanation = $derived.by(() => {
|
||||||
if (!deployment) return '';
|
if (!deployment) return '';
|
||||||
|
if (deployment.state === 'cancelled') return 'The queued removal was cancelled before removal began. The server and its files were kept.';
|
||||||
if (detail?.canResumeCleanup) return 'Komodo deletion succeeded. Resume the remaining file and database cleanup; the removed container does not need to answer RCON.';
|
if (detail?.canResumeCleanup) return 'Komodo deletion succeeded. Resume the remaining file and database cleanup; the removed container does not need to answer RCON.';
|
||||||
if (deployment.state === 'waiting' && deployment.stage === 'Removing server files and registry') return 'Local cleanup is queued. Komodo deletion has already succeeded.';
|
if (deployment.state === 'waiting' && deployment.stage === 'Removing server files and registry') return 'Local cleanup is queued. Komodo deletion has already succeeded.';
|
||||||
if (deployment.state === 'done') return deployment.action === 'create'
|
if (deployment.state === 'done') return deployment.action === 'create'
|
||||||
@@ -80,6 +81,17 @@
|
|||||||
} catch (err) { error = (err as Error).message; }
|
} catch (err) { error = (err as Error).message; }
|
||||||
finally { resuming = false; }
|
finally { resuming = false; }
|
||||||
}
|
}
|
||||||
|
async function cancelRemoval() {
|
||||||
|
if (!deployment || resuming) return;
|
||||||
|
resuming = true; error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/deployments/${encodeURIComponent(deployment.id)}/cancel`, { method: 'POST' });
|
||||||
|
const body = await response.json();
|
||||||
|
if (!response.ok) throw new Error(body.error ?? 'Could not cancel this request.');
|
||||||
|
await load();
|
||||||
|
} catch (err) { error = (err as Error).message; await load(); error = (err as Error).message; }
|
||||||
|
finally { resuming = false; }
|
||||||
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
@@ -99,7 +111,7 @@
|
|||||||
|
|
||||||
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
|
<svelte:head><title>{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry</title></svelte:head>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Destroy container' : 'Create server') : 'Deployment'} subtitle={deployment ? serverTitle(deployment.server) : undefined}>
|
<SectionHeader title={deployment ? (deployment.action === 'destroy' ? 'Remove server' : 'Create server') : 'Deployment'} subtitle={deployment ? serverTitle(deployment.server) : undefined}>
|
||||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
|
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Deployments', href: '/deployments' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
{#if error}<p role="alert" class="error">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||||
@@ -114,18 +126,21 @@
|
|||||||
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
|
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
|
||||||
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
|
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
|
||||||
{#if detail?.canResumeCleanup}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
|
{#if detail?.canResumeCleanup}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
|
||||||
|
{#if detail?.canCancelRemoval}<div><Button disabled={resuming} loading={resuming} onclick={cancelRemoval}>Cancel queued removal</Button><p class="muted">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
|
||||||
|
{#if deployment.server_exists && deployment.state !== 'done' && deployment.state !== 'cancelled'}<div class="recovery-links"><a href="/servers/{encodeURIComponent(deployment.server)}?tab=logs">Open startup logs</a><a href="/servers/{encodeURIComponent(deployment.server)}?tab=diagnostics">Diagnose connection</a>{#if deployment.stage.includes('map')}<a href="/servers/{encodeURIComponent(deployment.server)}?tab=config">Review map configuration</a>{/if}</div>{/if}
|
||||||
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
|
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
|
||||||
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
|
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
|
||||||
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
|
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
|
||||||
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
|
<dl><div><dt>Started</dt><dd>{fmt(deployment.started_at)}</dd></div><div><dt>Finished</dt><dd>{deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}</dd></div><div><dt>Requested by</dt><dd>{deployment.triggered_by}</dd></div></dl>
|
||||||
</section>
|
</section>
|
||||||
<OperationHistory steps={steps.map((label,index): OperationStep => ({label,state:index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} />
|
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch files'}</h2><Button variant="ghost" onclick={() => open = true}>Launch another server →</Button></div><p class="muted">Launch this map with the latest promoted files using a fresh server ID and port. This does not repair the existing server.</p><LaunchRecipe recipe={detail.recipe} historical /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
|
||||||
{#if detail?.recipe}<section class="saved-recipe"><div class="top"><h2>{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch files'}</h2><Button onclick={() => open = true}>Launch again →</Button></div><p class="muted">Launch this map with the latest promoted scripts, plugins, and configuration using a fresh lobby and port.</p><LaunchRecipe recipe={detail.recipe} historical /></section><DeployServerDialog bind:open sourceDeploymentId={deployment.id} />{/if}
|
<OperationHistory steps={steps.map((label,index): OperationStep => ({label,state:deployment.state === 'cancelled' ? 'pending' : index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} />
|
||||||
|
|
||||||
{:else if !error}<p>Loading deployment...</p>{/if}
|
{:else if !error}<p>Loading deployment...</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.recovery-links { display: flex; gap: 16px; flex-wrap: wrap; font-size: 14px; }
|
||||||
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
|
.page { display: grid; gap: 28px; } section { display: grid; gap: 16px; }
|
||||||
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
.summary { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
||||||
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
|
.saved-recipe { padding: 24px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }.explanation { color: var(--text-secondary); max-width: 800px; }.timing { display: flex; gap: 24px; font-size: 12px; color: var(--text-secondary); }.timing strong { color: var(--text-primary); margin-left: 8px; font-family: var(--font-mono); font-weight: 500; }
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
let search = $state('');
|
let search = $state('');
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let checkedAt = $state<Date | null>(null);
|
let checkedAt = $state<Date | null>(null);
|
||||||
|
let totalsOpen = $state(true);
|
||||||
|
let fullTable = $state(false);
|
||||||
let controller: AbortController | undefined;
|
let controller: AbortController | undefined;
|
||||||
let requestId = 0;
|
let requestId = 0;
|
||||||
let loadedUrl = '';
|
let loadedUrl = '';
|
||||||
@@ -63,6 +65,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
totalsOpen = !window.matchMedia('(max-width: 640px)').matches;
|
||||||
mounted = true;
|
mounted = true;
|
||||||
const refreshVisible = () => { if (!document.hidden && !loading) void refresh(apiUrl); };
|
const refreshVisible = () => { if (!document.hidden && !loading) void refresh(apiUrl); };
|
||||||
const interval = setInterval(refreshVisible, 15_000);
|
const interval = setInterval(refreshVisible, 15_000);
|
||||||
@@ -170,6 +173,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data}
|
{#if data}
|
||||||
|
<details class="totals" bind:open={totalsOpen}><summary>Fleet totals · {number.format(data.summary.players)} players · Round {number.format(data.summary.highestRound)} record</summary>
|
||||||
<div class="overview" aria-label="Totals for the selected game and map">
|
<div class="overview" aria-label="Totals for the selected game and map">
|
||||||
<div class="record"><span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong><small>Fleet record</small></div>
|
<div class="record"><span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong><small>Fleet record</small></div>
|
||||||
<div><span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small>Across all players</small></div>
|
<div><span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small>Across all players</small></div>
|
||||||
@@ -177,13 +181,15 @@
|
|||||||
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Includes active games</small></div>
|
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Includes active games</small></div>
|
||||||
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Across tracked games</small></div>
|
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Across tracked games</small></div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<section class="rankings" aria-labelledby="rankings-title" aria-busy={loading}>
|
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
|
||||||
<div class="rankings-header">
|
<div class="rankings-header">
|
||||||
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
|
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
|
||||||
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${checkedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' })}` : ''}<span>Refreshes every 15 seconds</span></div>
|
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${checkedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' })}` : ''}<span>Refreshes every 15 seconds</span></div>
|
||||||
</div>
|
</div>
|
||||||
{#if data.entries.length}
|
{#if data.entries.length}
|
||||||
|
<div class="mobile-view"><button class="text-button" aria-pressed={fullTable} onclick={() => fullTable = !fullTable}>{fullTable ? 'Show selected stat only' : 'Show all stats and player IDs'}</button></div>
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
||||||
<div class="table-scroll" role="region" aria-label="Player rankings" tabindex="0">
|
<div class="table-scroll" role="region" aria-label="Player rankings" tabindex="0">
|
||||||
<table>
|
<table>
|
||||||
@@ -239,12 +245,14 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.totals summary { cursor: pointer; padding: 10px 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
.mobile-view { display: none; }
|
||||||
.page { display: flex; flex-direction: column; gap: var(--space-5); }
|
.page { display: flex; flex-direction: column; gap: var(--space-5); }
|
||||||
.filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); padding: var(--space-5); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
.filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); padding: var(--space-5); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||||||
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
|
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
|
||||||
label { color: var(--text-secondary); font-size: var(--text-xs); }
|
label { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
input, select, button { font: inherit; }
|
input, select, button { font: inherit; }
|
||||||
input, select { min-height: 42px; width: 100%; padding: 9px 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); font-size: var(--text-sm); }
|
input, select { min-height: 42px; width: 100%; padding: 9px 12px; background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-md); color: var(--text-primary); font-size: var(--text-sm); }
|
||||||
input::placeholder { color: var(--text-tertiary); }
|
input::placeholder { color: var(--text-tertiary); }
|
||||||
.search > div { display: flex; gap: var(--space-2); }
|
.search > div { display: flex; gap: var(--space-2); }
|
||||||
.search input { flex: 1; min-width: 0; width: 0; }
|
.search input { flex: 1; min-width: 0; width: 0; }
|
||||||
@@ -298,6 +306,14 @@
|
|||||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||||
@media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .overview > div { padding: var(--space-4); } .overview small { display: none; } }
|
@media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .overview > div { padding: var(--space-4); } .overview small { display: none; } }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.mobile-view { display: block; padding: 0 16px 12px; }
|
||||||
|
.compact .stat:not(.selected) { display: none; }
|
||||||
|
.compact .player { min-width: 110px; max-width: 160px; position: static; }
|
||||||
|
.compact .identity :global(.copy) { display: none; }
|
||||||
|
.compact .stat { white-space: normal; }
|
||||||
|
.compact .rank { width: 44px; }
|
||||||
|
.compact th, .compact td { padding: 10px 8px; }
|
||||||
|
.compact thead button { white-space: normal; text-align: right; }
|
||||||
.page { gap: var(--space-4); }
|
.page { gap: var(--space-4); }
|
||||||
.filters { padding: var(--space-4); gap: var(--space-3); }
|
.filters { padding: var(--space-4); gap: var(--space-3); }
|
||||||
.search { grid-column: 1 / -1; }
|
.search { grid-column: 1 / -1; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { afterNavigate } from '$app/navigation';
|
||||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||||
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
import Field from '$ui/components/primitives/Field.svelte';
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
import Input from '$ui/components/primitives/Input.svelte';
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import Select from '$ui/components/primitives/Select.svelte';
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
|
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||||
|
|
||||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||||
interface ArtifactRow {
|
interface ArtifactRow {
|
||||||
@@ -39,6 +41,8 @@
|
|||||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
||||||
|
|
||||||
let addOpen = $state(false);
|
let addOpen = $state(false);
|
||||||
|
let githubBusy = $state(false);
|
||||||
|
let importComplete = $state<{ pluginId: number; betaUrl: string; alreadyImported: boolean } | null>(null);
|
||||||
let method = $state('upload');
|
let method = $state('upload');
|
||||||
let updatesOpen = $state(false);
|
let updatesOpen = $state(false);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
@@ -46,7 +50,11 @@
|
|||||||
let status = $state('');
|
let status = $state('');
|
||||||
let sort = $state('name');
|
let sort = $state('name');
|
||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
$effect(() => { search; game; status; sort; limit = 25; });
|
let mounted = $state(false);
|
||||||
|
afterNavigate(() => { mounted = true; });
|
||||||
|
let lastFilters = '';
|
||||||
|
$effect(() => { const key = JSON.stringify([search, game, status, sort]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
|
||||||
|
$effect(() => { if (mounted) saveLibraryContext({ q: search, game, status, sort, limit }, { sort: 'name', limit: 25 }); });
|
||||||
const visible = $derived.by(() => plugins.filter(a => (a.name + ' ' + a.target_rel_path).toLowerCase().includes(search.toLowerCase())
|
const visible = $derived.by(() => plugins.filter(a => (a.name + ' ' + a.target_rel_path).toLowerCase().includes(search.toLowerCase())
|
||||||
&& (!game || !a.scope_type || (a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? '')))
|
&& (!game || !a.scope_type || (a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? '')))
|
||||||
&& (!status || stateLabel(a) === status)).sort((a,b) => sort === 'recent' ? (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') : a.name.localeCompare(b.name)));
|
&& (!status || stateLabel(a) === status)).sort((a,b) => sort === 'recent' ? (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') : a.name.localeCompare(b.name)));
|
||||||
@@ -93,7 +101,7 @@
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMount(load);
|
onMount(() => { const params = new URLSearchParams(window.location.search); search = params.get('q') ?? ''; game = params.get('game') ?? ''; status = params.get('status') ?? ''; sort = params.get('sort') === 'recent' ? 'recent' : 'name'; limit = libraryLimit(params); void load(); });
|
||||||
|
|
||||||
function pickFile(e: Event): void {
|
function pickFile(e: Event): void {
|
||||||
const t = e.target as HTMLInputElement;
|
const t = e.target as HTMLInputElement;
|
||||||
@@ -156,25 +164,29 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Plugins · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Plugins" subtitle="Manage plugin versions and test changes before promotion.">
|
<SectionHeader title="Plugins" subtitle="Manage plugin versions and test changes before promotion.">
|
||||||
{#snippet actions()}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
|
{#snippet actions()}<Button variant="primary" onclick={() => addOpen = true}>Add plugin</Button>{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
{#if importComplete}<div class="banner" role="status"><strong>{importComplete.alreadyImported ? 'This plugin version was already imported.' : 'Plugin imported.'}</strong><span><a href={'/plugins/' + importComplete.pluginId}>View version</a> · <a href={importComplete.betaUrl}>Start beta</a></span></div>{/if}
|
||||||
{#if successBanner}
|
{#if successBanner}
|
||||||
<div class="banner">
|
<div class="banner" role="status">
|
||||||
<strong>{artifactVersionLabel(successBanner)} of "{successBanner.name}" uploaded — untested.</strong>
|
<strong>{artifactVersionLabel(successBanner)} of "{successBanner.name}" uploaded — untested.</strong>
|
||||||
<span><a href="/plugins/{successBanner.artifactId}">Review the version and start a beta</a></span>
|
<span><a href="/plugins/{successBanner.artifactId}">Review the version and start a beta</a></span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if flash && !addOpen}<div class="ui-error" role="alert">{flash}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
{#if flash && !addOpen}<div class="ui-error" role="alert">{flash}<Button size="sm" onclick={load}>Retry</Button></div>{/if}
|
||||||
<div class="ui-toolbar">
|
{#if loading || plugins.length || flash}<div class="ui-toolbar">
|
||||||
<input type="search" aria-label="Search plugins" bind:value={search} placeholder="Search names or files…" />
|
<input type="search" aria-label="Search plugins" bind:value={search} placeholder="Search names or files…" />
|
||||||
<select aria-label="Filter plugins by game" bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
|
<select aria-label="Filter plugins by game" bind:value={game}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select>
|
||||||
<select aria-label="Filter plugins by status" bind:value={status}><option value="">All statuses</option><option value="untested">Needs testing</option><option value="testing">Beta active</option><option value="test-failed">Test failed</option><option value="tested">Test passed</option><option value="promoted">Promoted</option></select>
|
<select aria-label="Filter plugins by status" bind:value={status}><option value="">All statuses</option><option value="untested">Needs testing</option><option value="testing">Beta active</option><option value="test-failed">Test failed</option><option value="tested">Test passed</option><option value="promoted">Promoted</option></select>
|
||||||
<select aria-label="Sort plugins" bind:value={sort}><option value="name">Name A–Z</option><option value="recent">Recently uploaded</option></select>
|
<select aria-label="Sort plugins" bind:value={sort}><option value="name">Name A–Z</option><option value="recent">Recently uploaded</option></select>
|
||||||
</div>
|
</div>{/if}
|
||||||
<details ontoggle={event => updatesOpen = event.currentTarget.open}><summary>Check plugin updates</summary>{#if updatesOpen}{#key importCount}<PluginUpdates />{/key}{/if}</details>
|
{#if game}<p class="filter-hint">Game results include unscoped plugins that can use explicitly selected beta servers.</p>{/if}
|
||||||
|
{#if plugins.length}<details ontoggle={event => updatesOpen = event.currentTarget.open}><summary>Check plugin updates</summary>{#if updatesOpen}{#key importCount}<PluginUpdates />{/key}{/if}</details>{/if}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>All plugins</h2>
|
<h2>All plugins</h2>
|
||||||
@@ -214,10 +226,10 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading} wide>
|
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
|
||||||
<Tabs tabs={[{id:'upload',label:'Upload file',disabled:uploading},{id:'github',label:'GitHub release',disabled:uploading}]} bind:value={method} ariaLabel="Plugin source" idPrefix="plugin-source" panelId="plugin-source-panel" />
|
<Tabs tabs={[{id:'upload',label:'Upload file',disabled:uploading || githubBusy},{id:'github',label:'GitHub release',disabled:uploading || githubBusy}]} bind:value={method} ariaLabel="Plugin source" idPrefix="plugin-source" panelId="plugin-source-panel" />
|
||||||
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
|
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
|
||||||
{#if method === 'github'}<PluginGithubImport onimport={async () => { importCount++; addOpen = false; await load(); }} />
|
{#if method === 'github'}<PluginGithubImport bind:busy={githubBusy} onimport={async result => { importComplete = result; importCount++; addOpen = false; await load(); }} />
|
||||||
{:else} <form onsubmit={createPlugin} class="upload-form">
|
{:else} <form onsubmit={createPlugin} class="upload-form">
|
||||||
<Field label="Name">
|
<Field label="Name">
|
||||||
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
|
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
|
||||||
@@ -232,7 +244,7 @@
|
|||||||
<div class="scope-row">
|
<div class="scope-row">
|
||||||
<Select ariaLabel="Scope type" bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
|
<Select ariaLabel="Scope type" bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
|
||||||
{#if newScopeType === 'game' || newScopeType === 'map'}
|
{#if newScopeType === 'game' || newScopeType === 'map'}
|
||||||
<Select ariaLabel="Scope game" bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
|
<Select ariaLabel="Scope game" bind:value={newScopeGame} onchange={() => newScopeMap = ''} options={gameOptions} disabled={uploading} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if newScopeType === 'map'}
|
{#if newScopeType === 'map'}
|
||||||
<Select ariaLabel="Scope map" bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
|
<Select ariaLabel="Scope map" bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
|
||||||
@@ -251,7 +263,7 @@
|
|||||||
>
|
>
|
||||||
Upload plugin
|
Upload plugin
|
||||||
</Button>
|
</Button>
|
||||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
{#if flash}<span class="flash" role="alert">{flash}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -259,6 +271,7 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
<style>
|
<style>
|
||||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||||
|
.filter-hint { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||||
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
||||||
|
|
||||||
.upload-form { margin-top: 20px; }
|
.upload-form { margin-top: 20px; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { afterNavigate } from '$app/navigation';
|
||||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
@@ -9,9 +10,14 @@
|
|||||||
import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte';
|
import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte';
|
||||||
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
|
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
|
||||||
import { zombieMaps } from '$lib/map-aliases.js';
|
import { zombieMaps } from '$lib/map-aliases.js';
|
||||||
|
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||||
|
|
||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
$effect(() => { search; status; game; map; sort; limit = 25; });
|
let mounted = $state(false);
|
||||||
|
afterNavigate(() => { mounted = true; });
|
||||||
|
let lastFilters = '';
|
||||||
|
$effect(() => { const key = JSON.stringify([search, status, game, map, sort]); if (!mounted) return; if (lastFilters && lastFilters !== key) limit = 25; lastFilters = key; });
|
||||||
|
$effect(() => { if (mounted) saveLibraryContext({ q: search, status, game, map, sort, limit, selected: selected.join(',') }, { status: 'all', sort: 'attention', limit: 25 }); });
|
||||||
let scripts = $state<LibraryScript[]>([]);
|
let scripts = $state<LibraryScript[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -34,7 +40,7 @@
|
|||||||
const haystack = `${s.name} ${s.target_rel_path} ${s.scope_value ?? ''} ${scopeLabel(s)}`.toLowerCase();
|
const haystack = `${s.name} ${s.target_rel_path} ${s.scope_value ?? ''} ${scopeLabel(s)}`.toLowerCase();
|
||||||
return search.toLowerCase().trim().split(/\s+/).every(term => haystack.includes(term))
|
return search.toLowerCase().trim().split(/\s+/).every(term => haystack.includes(term))
|
||||||
&& (status === 'all' || scriptStatus(s) === status)
|
&& (status === 'all' || scriptStatus(s) === status)
|
||||||
&& (!game || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
|
&& (!game || !s.scope_type || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
|
||||||
&& (!map || s.scope_type === 'map' && s.scope_value === map);
|
&& (!map || s.scope_type === 'map' && s.scope_value === map);
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
if (sort === 'recent') return (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') || a.name.localeCompare(b.name);
|
if (sort === 'recent') return (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') || a.name.localeCompare(b.name);
|
||||||
@@ -42,6 +48,8 @@
|
|||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
}));
|
}));
|
||||||
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
|
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
|
||||||
|
const hiddenSelected = $derived(selectedScripts.filter(s => !visible.slice(0, limit).some(row => row.id === s.id)).length);
|
||||||
|
let reviewSelection = $state(false);
|
||||||
const selectable = $derived(visible.slice(0,limit).filter(canTest));
|
const selectable = $derived(visible.slice(0,limit).filter(canTest));
|
||||||
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
|
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -54,7 +62,14 @@
|
|||||||
} catch (e) { error = (e as Error).message; }
|
} catch (e) { error = (e as Error).message; }
|
||||||
finally { loading = false; }
|
finally { loading = false; }
|
||||||
}
|
}
|
||||||
onMount(() => { void load(); });
|
onMount(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
search = params.get('q') ?? ''; game = params.get('game') ?? ''; map = params.get('map') ?? '';
|
||||||
|
status = tabs.some(t => t.value === params.get('status')) ? params.get('status') as ScriptStatus : 'all';
|
||||||
|
sort = ['attention', 'recent', 'name'].includes(params.get('sort') ?? '') ? params.get('sort')! : 'attention';
|
||||||
|
limit = libraryLimit(params); selected = (params.get('selected') ?? '').split(',').map(Number).filter(id => Number.isSafeInteger(id) && id > 0).slice(0, 500);
|
||||||
|
void load();
|
||||||
|
});
|
||||||
function toggle(id: number) { selected = selected.includes(id) ? selected.filter(n => n !== id) : [...selected, id]; }
|
function toggle(id: number) { selected = selected.includes(id) ? selected.filter(n => n !== id) : [...selected, id]; }
|
||||||
function selectVisible() {
|
function selectVisible() {
|
||||||
const ids = selectable.map(s => s.id);
|
const ids = selectable.map(s => s.id);
|
||||||
@@ -70,6 +85,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Scripts · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
<svelte:window ondragover={onDrag} ondrop={onDrop}
|
<svelte:window ondragover={onDrag} ondrop={onDrop}
|
||||||
ondragenter={event => { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }}
|
ondragenter={event => { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }}
|
||||||
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
|
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
|
||||||
@@ -78,15 +95,15 @@
|
|||||||
<SectionHeader title="Scripts" subtitle="Review versions, prepare a beta, and keep releases moving.">
|
<SectionHeader title="Scripts" subtitle="Review versions, prepare a beta, and keep releases moving.">
|
||||||
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
|
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<div class="status-grid" aria-label="Filter by script status">
|
{#if loading || scripts.length || error}<div class="status-grid" aria-label="Filter by script status">
|
||||||
{#each tabs as tab}
|
{#each tabs as tab}
|
||||||
<button class:active={status === tab.value} aria-pressed={status === tab.value} onclick={() => status = tab.value}>
|
<button class:active={status === tab.value} aria-pressed={status === tab.value} onclick={() => status = tab.value}>
|
||||||
<span>{tab.label}</span><strong>{loading ? '—' : scripts.filter(s => tab.value === 'all' || scriptStatus(s) === tab.value).length}</strong>
|
<span>{tab.label}</span><strong>{loading ? '—' : scripts.filter(s => tab.value === 'all' || scriptStatus(s) === tab.value).length}</strong>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>{/if}
|
||||||
<section class="library" aria-label="Script library">
|
<section class="library" aria-label="Script library">
|
||||||
<div class="toolbar">
|
{#if loading || scripts.length || error}<div class="toolbar">
|
||||||
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
|
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
|
||||||
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
|
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
|
||||||
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
|
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
|
||||||
@@ -96,7 +113,8 @@
|
|||||||
<label class="select-all"><input type="checkbox" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /> Select visible</label>
|
<label class="select-all"><input type="checkbox" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /> Select visible</label>
|
||||||
<span>{visible.length} of {scripts.length} scripts</span>
|
<span>{visible.length} of {scripts.length} scripts</span>
|
||||||
{#if search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
|
{#if search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
|
||||||
</div>
|
</div>{/if}
|
||||||
|
{#if game}<p class="hint">Game results include unscoped scripts that can use explicitly selected beta servers.</p>{/if}
|
||||||
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
|
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
|
||||||
{#if loading}<p class="loading" role="status">Loading your library…</p>
|
{#if loading}<p class="loading" role="status">Loading your library…</p>
|
||||||
{:else if !scripts.length && !error}
|
{:else if !scripts.length && !error}
|
||||||
@@ -134,9 +152,11 @@
|
|||||||
</section>
|
</section>
|
||||||
{#if selectedScripts.length}
|
{#if selectedScripts.length}
|
||||||
<div class="selection-bar" role="region" aria-label="Selected scripts">
|
<div class="selection-bar" role="region" aria-label="Selected scripts">
|
||||||
<strong>{selectedScripts.length} selected</strong><span>Latest versions will be carried into beta setup.</span>
|
<strong title={hiddenSelected ? 'Some selected scripts are outside the current filters or loaded rows.' : undefined}>{selectedScripts.length} selected{hiddenSelected ? ` · ${hiddenSelected} hidden` : ''}</strong><span>Latest versions will be carried into beta setup.</span>
|
||||||
|
<Button size="sm" variant="ghost" ariaLabel="Review selected scripts" onclick={() => reviewSelection = !reviewSelection}>Review</Button>
|
||||||
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
|
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
|
||||||
<Button variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
|
<Button size="sm" variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
|
||||||
|
{#if reviewSelection}<ul class="selection-review">{#each selectedScripts as script}<li>{script.name} · v{script.latestVersion?.version}<Button size="sm" variant="ghost" onclick={() => toggle(script.id)} ariaLabel={`Remove ${script.name} from selection`}>Remove</Button></li>{/each}</ul>{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,6 +184,9 @@
|
|||||||
.hint { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 20px; line-height: 1.6; }
|
.hint { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 20px; line-height: 1.6; }
|
||||||
.selection-bar { position: sticky; bottom: 20px; display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--accent-primary); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: 0 8px 32px #0005; }
|
.selection-bar { position: sticky; bottom: 20px; display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--accent-primary); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: 0 8px 32px #0005; }
|
||||||
.selection-bar span { flex: 1; color: var(--text-secondary); font-size: var(--text-sm); }
|
.selection-bar span { flex: 1; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
|
.selection-review { flex-basis: 100%; margin: 0; padding: 0; list-style: none; max-height: 240px; overflow: auto; }
|
||||||
|
.selection-review li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 6px 0; }
|
||||||
|
@media (max-width: 640px) { .selection-bar { padding: 12px; gap: 8px; bottom: 12px; } .selection-bar > strong { flex-basis: 100%; font-size: var(--text-sm); } }
|
||||||
.drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); }
|
.drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); }
|
||||||
.drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
|
.drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||||
@@ -172,4 +195,5 @@
|
|||||||
.status-grid { display:flex; gap:8px; overflow-x:auto; }
|
.status-grid { display:flex; gap:8px; overflow-x:auto; }
|
||||||
.status-grid button { flex-direction:row; align-items:center; flex-shrink:0; padding:8px 12px; gap:8px; }
|
.status-grid button { flex-direction:row; align-items:center; flex-shrink:0; padding:8px 12px; gap:8px; }
|
||||||
.status-grid strong { font-size:13px; }
|
.status-grid strong { font-size:13px; }
|
||||||
|
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { zombieMaps, zombieMapBySlug, type ZombieGame } from '$lib/map-aliases.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
|
||||||
@@ -15,11 +18,21 @@
|
|||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
$effect(() => { search; limit = 25; });
|
|
||||||
let keys = $state<SavedKey[]>([]);
|
let keys = $state<SavedKey[]>([]);
|
||||||
let serverId = $state('');
|
let serverId = $state('');
|
||||||
let plutoKey = $state('');
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
|
let rawMode = $state(false);
|
||||||
|
let game = $state<ZombieGame>('t5');
|
||||||
|
let mapSlug = $state('theater');
|
||||||
|
let lobby = $state(1);
|
||||||
|
const chosenId = $derived(editing || rawMode ? serverId.trim() : `${game}_zombie_${mapSlug}_${lobby}`);
|
||||||
|
const validId = $derived.by(() => {
|
||||||
|
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(chosenId);
|
||||||
|
return !!match && !!zombieMapBySlug(match[1], match[2]);
|
||||||
|
});
|
||||||
|
$effect(() => { search = $page.url.searchParams.get('server') ?? ''; limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
|
||||||
|
function saveSearch() { const url = new URL($page.url); if (search) url.searchParams.set('server', search); else url.searchParams.delete('server'); if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit'); void goto(url, { replaceState: true, keepFocus: true, noScroll: true }); }
|
||||||
|
let plutoKey = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -33,14 +46,22 @@
|
|||||||
}
|
}
|
||||||
async function reload() { try { await load(); error = ''; } catch (e) { error = (e as Error).message; } finally { loading = false; } }
|
async function reload() { try { await load(); error = ''; } catch (e) { error = (e as Error).message; } finally { loading = false; } }
|
||||||
onMount(() => { void reload(); });
|
onMount(() => { void reload(); });
|
||||||
function reset() { serverId = ''; plutoKey = ''; editing = false; }
|
function reset() { serverId = ''; plutoKey = ''; editing = false; rawMode = false; }
|
||||||
|
function addKey() {
|
||||||
|
reset(); error = '';
|
||||||
|
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec($page.url.searchParams.get('server') ?? '');
|
||||||
|
if (match && zombieMapBySlug(match[1], match[2])) { game = match[1] as ZombieGame; mapSlug = match[2]; lobby = Number(match[3]); }
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
function savedAt(value: string) { return new Date(value.includes('T') ? value : value.replace(' ', 'T') + 'Z').toLocaleString(); }
|
||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!validId || !plutoKey.trim() || busy) return;
|
||||||
busy = true; error = ''; notice = '';
|
busy = true; error = ''; notice = '';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/server-keys', {
|
const res = await fetch('/api/server-keys', {
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ serverId: serverId.trim(), plutoKey })
|
body: JSON.stringify({ serverId: chosenId, plutoKey })
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
|
if (!res.ok) throw new Error(body.error ?? 'Could not save key');
|
||||||
@@ -67,9 +88,9 @@
|
|||||||
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
|
<svelte:head><title>Keys · Electric Cherry</title></svelte:head>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Server keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
|
<SectionHeader title="Server keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
|
||||||
{#snippet actions()}<Button variant="primary" onclick={() => { reset(); error = ''; open = true; }}>Add key</Button>{/snippet}
|
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
<div class="ui-toolbar"><input type="search" aria-label="Search server keys" bind:value={search} placeholder="Search servers…" /></div>
|
<div class="ui-toolbar"><input type="search" aria-label="Search server keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search servers…" /></div>
|
||||||
|
|
||||||
{#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if}
|
{#if error && !open}<div role="alert" class="ui-error">{error}<Button size="sm" onclick={reload}>Retry</Button></div>{/if}
|
||||||
{#if notice}<p role="status">{notice}</p>{/if}
|
{#if notice}<p role="status">{notice}</p>{/if}
|
||||||
@@ -81,36 +102,42 @@
|
|||||||
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
|
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
|
||||||
<tbody>{#each filtered.slice(0,limit) as key (key.server_id)}
|
<tbody>{#each filtered.slice(0,limit) as key (key.server_id)}
|
||||||
<tr>
|
<tr>
|
||||||
<td><ServerIdentity name={key.server_id} /><code>{key.server_id}</code></td>
|
<td>{#if key.deployed}<a href="/servers/{encodeURIComponent(key.server_id)}"><ServerIdentity name={key.server_id} /></a>{:else}<ServerIdentity name={key.server_id} />{/if}<code>{key.server_id}</code></td>
|
||||||
<td>{key.deployed ? 'Deployed' : 'Not deployed'}</td>
|
<td>{key.deployed ? 'Server exists' : 'No server deployed'}</td>
|
||||||
<td aria-label="Key saved">••••••••</td>
|
<td>Stored, not validated<small>Saved {savedAt(key.updated_at)}<br />Used on next deployment</small></td>
|
||||||
<td><div class="actions">
|
<td><div class="actions">
|
||||||
<Button disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button>
|
<Button ariaLabel={`Update key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => { serverId = key.server_id; plutoKey = ''; editing = true; error = ''; notice = ''; open = true; }}>Update</Button>
|
||||||
<Button disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
|
<Button ariaLabel={`Remove key for ${serverTitle(key.server_id)}`} disabled={busy} onclick={() => remove(key.server_id)}>Remove</Button>
|
||||||
</div></td>
|
</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}</tbody>
|
{/each}</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ListFooter total={filtered.length} bind:limit label="keys" />
|
<ListFooter total={filtered.length} bind:limit onchange={saveSearch} label="keys" />
|
||||||
</div>
|
</div>
|
||||||
<Dialog bind:open title={editing ? 'Update key' : 'Add key'} busy={busy}>
|
<Dialog bind:open title={editing ? 'Update key' : 'Add key'} busy={busy}>
|
||||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||||
<form onsubmit={save}>
|
<form onsubmit={save}>
|
||||||
<Field label="Server ID" hint="Use a T5 or T6 map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2 or t6_zombie_tomb_1.">
|
{#if !editing}<label class="raw-toggle"><input type="checkbox" bind:checked={rawMode} disabled={busy} onchange={() => { if (!rawMode) serverId = `${game}_zombie_${mapSlug}_${lobby}`; }} /> Enter a raw server ID</label>{/if}
|
||||||
|
{#if editing || rawMode}<Field label="Server ID" hint="Use a T5 or T6 map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2 or t6_zombie_tomb_1." error={!validId && serverId ? 'Choose a recognized map and lobby number 1–20.' : null}>
|
||||||
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
|
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
|
||||||
</Field>
|
</Field>{:else}<div class="id-picker"><label>Game<select bind:value={game} disabled={busy} onchange={event => mapSlug = zombieMaps(event.currentTarget.value)[0].slug}><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label><label>Map<select bind:value={mapSlug} disabled={busy}>{#each zombieMaps(game) as map}<option value={map.slug}>{map.friendly}</option>{/each}</select></label><label>Lobby<input type="number" min="1" max="20" step="1" bind:value={lobby} disabled={busy} required /></label></div><p>Server ID: <code>{chosenId}</code></p>{/if}
|
||||||
|
{#if !editing && keys.some(key => key.server_id === chosenId)}<p role="status">A key is already saved for this ID. Saving replaces it for the next deployment; the running server keeps its current key.</p>{/if}
|
||||||
<Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed.">
|
<Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed.">
|
||||||
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
|
<Input type="password" bind:value={plutoKey} placeholder="Paste key" autocomplete="off" required disabled={busy} />
|
||||||
</Field>
|
</Field>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button type="submit" variant="primary" disabled={busy || loading} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
|
<Button type="submit" variant="primary" disabled={busy || loading || !validId || !plutoKey.trim()} loading={busy}>{editing ? 'Update key' : 'Save key'}</Button>
|
||||||
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
|
<Button onclick={() => open = false} disabled={busy}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{#if !validId || !plutoKey.trim()}<p role="status">Choose a valid server ID and enter its Plutonium key to save.</p>{/if}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<style>
|
<style>
|
||||||
|
a { color: var(--accent-primary-hover); }small { display: block; color: var(--text-secondary); font-size: 12px; line-height: 1.6; margin-top: 5px; }
|
||||||
|
.id-picker { display: grid; grid-template-columns: 1fr 1.4fr 80px; gap: 12px; }.id-picker label { display: grid; gap: 8px; font-size: 14px; }.id-picker input, .id-picker select { width: 100%; min-width: 0; padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; }.raw-toggle { display: flex; gap: 8px; align-items: center; font-size: 14px; }
|
||||||
|
@media (max-width: 600px) { .id-picker { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
h2 { font-size: var(--text-md); }
|
h2 { font-size: var(--text-md); }
|
||||||
p { color: var(--text-secondary); }
|
p { color: var(--text-secondary); }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
|
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
|
||||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||||
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
|
||||||
@@ -100,7 +101,13 @@
|
|||||||
let rconProbe = $state<RconProbeResult | null>(null);
|
let rconProbe = $state<RconProbeResult | null>(null);
|
||||||
let probingRcon = $state(false);
|
let probingRcon = $state(false);
|
||||||
let destroyOpen = $state(false);
|
let destroyOpen = $state(false);
|
||||||
let activeTab = $state('scoreboard');
|
const sectionIds = ['scoreboard', 'logs', 'config', 'inventory', 'diagnostics', 'history'];
|
||||||
|
const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'scoreboard');
|
||||||
|
function changeTab(tab: string) {
|
||||||
|
const url = new URL($page.url);
|
||||||
|
url.searchParams.set('tab', tab);
|
||||||
|
void goto(url, { noScroll: true, keepFocus: true });
|
||||||
|
}
|
||||||
let tabLoading = $state(false);
|
let tabLoading = $state(false);
|
||||||
let tabError = $state('');
|
let tabError = $state('');
|
||||||
let historyLimit = $state(25);
|
let historyLimit = $state(25);
|
||||||
@@ -147,10 +154,10 @@
|
|||||||
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
|
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
|
||||||
let effectiveGameStartedAt = $derived<string | null>(server?.game_started_at ?? null);
|
let effectiveGameStartedAt = $derived<string | null>(server?.game_started_at ?? null);
|
||||||
let effectiveGameEndedAt = $derived<string | null>(server?.game_ended_at ?? null);
|
let effectiveGameEndedAt = $derived<string | null>(server?.game_ended_at ?? null);
|
||||||
let activeElapsed = $derived(effectiveHealth !== 'starting' && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
|
let activeElapsed = $derived(fresh && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
|
||||||
let inactiveElapsed = $derived(effectiveHealth !== 'starting' && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
|
let inactiveElapsed = $derived(fresh && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
|
||||||
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
|
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
|
||||||
let gameElapsed = $derived(effectiveHealth !== 'starting' && gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null);
|
let gameElapsed = $derived(fresh && gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null);
|
||||||
|
|
||||||
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting');
|
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting');
|
||||||
|
|
||||||
@@ -212,6 +219,7 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{serverTitle(name)} · {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} · Electric Cherry</title></svelte:head>
|
||||||
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} />
|
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} />
|
||||||
|
|
||||||
{#if server}
|
{#if server}
|
||||||
@@ -223,9 +231,7 @@
|
|||||||
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
|
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="danger" onclick={() => destroyOpen = true}>
|
<details class="server-actions"><summary>Server actions</summary><Button variant="danger" onclick={() => destroyOpen = true}>Queue server removal</Button></details>
|
||||||
Destroy
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
|
||||||
@@ -280,9 +286,15 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
|
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
|
||||||
|
{#if hasDiagnosticIssue}
|
||||||
|
<section class="connection-issue" aria-label="Server connection issue">
|
||||||
|
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</p></div>
|
||||||
|
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if integrations && (!integrations.rcon || !integrations.komodo)}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
{#if operations.length}<section class="ui-panel"><h2>Current operations</h2><div class="server-operations">{#each operations as operation (operation.key)}<a href={operation.href}><strong>{operation.label}</strong><span>{operation.detail}</span><StatusBadge state={operation.state} /></a>{/each}</div></section>{/if}
|
{#if operations.length}<section class="ui-panel"><h2>Current operations</h2><div class="server-operations">{#each operations as operation (operation.key)}<a href={operation.href}><strong>{operation.label}</strong><span>{operation.detail}</span><StatusBadge state={operation.state} /></a>{/each}</div></section>{/if}
|
||||||
<div class="tabs-row">
|
<div class="tabs-row">
|
||||||
<Tabs {tabs} bind:value={activeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
|
<Tabs {tabs} value={activeTab} onchange={changeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" class:diagnostics={activeTab === 'diagnostics'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}>
|
<div class="tab-panel" class:diagnostics={activeTab === 'diagnostics'} role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}>
|
||||||
@@ -329,9 +341,6 @@
|
|||||||
</Card>
|
</Card>
|
||||||
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
|
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
|
||||||
{:else if activeTab === 'diagnostics'}
|
{:else if activeTab === 'diagnostics'}
|
||||||
{#key name}
|
|
||||||
<TelemetryDiagnostics server={name} />
|
|
||||||
{/key}
|
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>RCON diagnostic</h2>
|
<h2>RCON diagnostic</h2>
|
||||||
@@ -493,6 +502,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div>
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
@@ -537,6 +547,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.server-actions { text-align: right; }
|
||||||
|
.server-actions summary { cursor: pointer; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||||
|
.server-actions[open] summary { margin-bottom: 10px; }
|
||||||
|
.connection-issue { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); background: var(--surface); }
|
||||||
|
.connection-issue p { margin: 5px 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
|
.issue-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.server-page {
|
.server-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { SystemStatus } from '$lib/system-status.js';
|
||||||
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
|
let data = $state<SystemStatus | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
async function refresh(probe = false) {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true; error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/system-status' + (probe ? '?probe=1' : ''));
|
||||||
|
if (!response.ok) throw new Error('Could not load system status. Try again.');
|
||||||
|
data = await response.json();
|
||||||
|
} catch (cause) { error = (cause as Error).message; }
|
||||||
|
finally { loading = false; }
|
||||||
|
}
|
||||||
|
onMount(() => { void refresh(); });
|
||||||
|
const labels = { ready: 'Ready', attention: 'Needs attention', unconfigured: 'Not configured', unverified: 'Not verified' };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>System status · Electric Cherry</title></svelte:head>
|
||||||
|
<section class="page">
|
||||||
|
<SectionHeader title="System status" subtitle="Check your connections and the prerequisites for a playable server.">
|
||||||
|
{#snippet actions()}<Button loading={loading} onclick={() => refresh(true)}>Check connections</Button>{/snippet}
|
||||||
|
</SectionHeader>
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}<Button size="sm" onclick={() => refresh()}>Retry</Button></p>{/if}
|
||||||
|
{#if data}
|
||||||
|
<p class="checked">Checked {new Date(data.checkedAt).toLocaleString()} · {data.readyServers} / {data.totalServers} servers ready</p>
|
||||||
|
<ol class="checks">{#each data.checks as check}<li><div class="heading"><h2>{check.title}</h2><Badge variant={check.state === 'ready' ? 'online' : check.state === 'unverified' ? 'neutral' : 'warning'}>{labels[check.state]}</Badge></div><p>{check.detail}</p>{#if check.href}<a href={check.href}>{check.action} →</a>{/if}</li>{/each}</ol>
|
||||||
|
{:else if loading}<p role="status">Checking system setup…</p>{/if}
|
||||||
|
<section id="setup-guide" class="guide"><h2>Connection setup</h2><p>Set these variables on the Electric Cherry container in Komodo, then restart the app to load the changes. Credentials are kept on the server and are never returned by this page.</p><dl><dt>Komodo</dt><dd><code>KOMODO_ADDRESS</code>, <code>KOMODO_API_KEY</code>, <code>KOMODO_API_SECRET</code>, and <code>KOMODO_TARGET_SERVER</code>. The target must match the host name in Komodo.</dd><dt>Game servers</dt><dd><code>RCON_PASSWORD</code> must match the game-server password. Set <code>RCON_HOST</code> if the default <code>host.docker.internal</code> does not reach the game host. Forward the allocated UDP game ports.</dd></dl></section>
|
||||||
|
<section id="game-files" class="guide"><h2>Game files and keys</h2><p>Populate the Komodo host's <code>t5_game_files</code> or <code>t6_game_files</code> Docker volume with the corresponding game files and required DLC. Override those names using <code>PLUTAINER_GAMEFILES_VOLUME</code> or <code>PLUTAINER_T6_GAMEFILES_VOLUME</code>. Electric Cherry and the host must see each server directory at the same absolute path.</p><p>Create a Plutonium server key for the correct game and save it for the exact game, map, and lobby in <a href="/server-keys">Server keys</a>. Use a launch preview to review the assigned ID and port before creating a server.</p></section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page { display: grid; gap: 20px; }
|
||||||
|
.checked { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; }
|
||||||
|
.checks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.checks li, .guide { padding: 20px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); }
|
||||||
|
.heading { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
|
||||||
|
h2 { font-size: var(--text-md); margin: 0; }
|
||||||
|
p, dd { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.65; }
|
||||||
|
a { font-size: var(--text-sm); }
|
||||||
|
dt { font-weight: var(--weight-semibold); margin-top: 12px; }
|
||||||
|
dd { margin: 5px 0 0; }
|
||||||
|
code { font-size: var(--text-xs); }
|
||||||
|
.guide { scroll-margin-top: 90px; }
|
||||||
|
@media (max-width: 700px) { .checks { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { inspectConfig, type ConfigLayer } from '../../src/lib/server/fleet/config-inspect.js';
|
import { inspectConfig, type ConfigLayer } from '../../src/lib/server/fleet/config-inspect.js';
|
||||||
import { composeServerCfg } from '../../src/lib/server/fleet/config-compose.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 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' } });
|
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', () => {
|
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', () => {
|
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';
|
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');
|
expect(changeConfigValue(text, 1, '"2" // changed')).toBe('// note\r\n set a "2" // changed\r\nexec other.cfg\r\nset a 5\n');
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('config workbench API', () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const result = await response.json();
|
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.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(listVersions(overlay.script.id)).toHaveLength(1);
|
||||||
expect(getScript(baseline.script.id)?.current_version_id).toBe(baseline.version.id);
|
expect(getScript(baseline.script.id)?.current_version_id).toBe(baseline.version.id);
|
||||||
const { getPromotedServerConfig } = await import('../../src/lib/server/fleet/server-config.js');
|
const { getPromotedServerConfig } = await import('../../src/lib/server/fleet/server-config.js');
|
||||||
|
|||||||
@@ -48,6 +48,60 @@ afterEach(async () => {
|
|||||||
vi.unstubAllEnvs();
|
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', () => {
|
describe('destruction cleanup recovery', () => {
|
||||||
it('removes T6 servers with beta test runs and their dependent evidence', async () => {
|
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');
|
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
|||||||
@@ -21,6 +21,57 @@ afterEach(async () => {
|
|||||||
await fixture.cleanup();
|
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', () => {
|
describe('drift scanning', () => {
|
||||||
it('replaces a seeded config once instead of planning both removal and installation', async () => {
|
it('replaces a seeded config once instead of planning both removal and installation', async () => {
|
||||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user