Restore campaign terminology across UI and docs

This commit is contained in:
2026-09-19 22:41:33 -04:00
parent 05024c7675
commit 68b21a83ce
19 changed files with 56 additions and 56 deletions
+2 -2
View File
@@ -65,7 +65,7 @@ See [Plugin releases](docs/plugin-releases.md) for the import and beta workflow.
## How restarts stay safe ## How restarts stay safe
Betas support configurable gameplay, participation, stability and manual-review Betas support configurable gameplay, participation, stability and manual-review
requirements, saved presets, coverage rules and staged rollout. See requirements, saved presets, coverage rules and staged waves. See
[Beta test plans](docs/beta-test-plans.md) for usage and the four-event game telemetry contract. [Beta test plans](docs/beta-test-plans.md) for usage and the four-event game telemetry contract.
File changes are staged sequentially. Their activation uses rolling restarts: File changes are staged sequentially. Their activation uses rolling restarts:
@@ -309,7 +309,7 @@ RCON parsing (fake UDP socket).
## Campaigns and server deployments ## Campaigns and server deployments
Campaigns (`/campaigns`) contains script/config rollouts, drift reconciliation, restart progress, file diffs, and rollbacks. Existing rollout records are preserved in the original database tables. The campaign API lives at `/api/campaigns`; legacy rollout detail API endpoints remain available for compatibility. Campaigns (`/campaigns`) tracks script/config updates, drift reconciliation, restart progress, file diffs, and rollbacks. Existing campaign records are preserved in the original database tables. The campaign API lives at `/api/campaigns`; legacy detail API endpoints remain available for compatibility.
Deployments (`/deployments`, `/api/deployments`) now tracks server creations and destructions with live stages and persistent history, including failures, warnings, and interrupted operations. History starts with operations performed after this update and survives server removal. New creation requests return HTTP 202 with a deployment ID and open the deployment detail page. Creation is complete only when Komodo reports healthy, RCON responds successfully, and the RCON map exactly matches the selected map. Queued requests reserve their server name and port; queued creation and pending health checks survive application restarts. The detail page records startup progress and explains any health or map mismatch that is still blocking completion. Deployments (`/deployments`, `/api/deployments`) now tracks server creations and destructions with live stages and persistent history, including failures, warnings, and interrupted operations. History starts with operations performed after this update and survives server removal. New creation requests return HTTP 202 with a deployment ID and open the deployment detail page. Creation is complete only when Komodo reports healthy, RCON responds successfully, and the RCON map exactly matches the selected map. Queued requests reserve their server name and port; queued creation and pending health checks survive application restarts. The detail page records startup progress and explains any health or map mismatch that is still blocking completion.
+2 -2
View File
@@ -165,7 +165,7 @@ snapshots, not continuous measurements of player presence.
Health checks and safe restart gates use the existing container/RCON monitoring. Health checks and safe restart gates use the existing container/RCON monitoring.
Health intervals spanning more than 60 seconds earn no healthy time. Health intervals spanning more than 60 seconds earn no healthy time.
## Coverage, rollout and outcomes ## Coverage, waves and outcomes
Coverage can require all selected servers, a quorum, one per selected map, Coverage can require all selected servers, a quorum, one per selected map,
one per selected game, or one per selected game/map combination. Coverage one per selected game, or one per selected game/map combination. Coverage
@@ -183,7 +183,7 @@ For failures, select hold as failed, pause for investigation, or automatic
rollback. Rollback restores only files touched by the beta. Restarts can wait rollback. Rollback restores only files touched by the beta. Restarts can wait
for empty servers or remain manual. Promotion is always manual. for empty servers or remain manual. Promotion is always manual.
Pause holds evaluation and rollout. Resume retains completed progress and the Pause holds evaluation and wave advancement. Resume retains completed progress and the
original deadline, but starts new timing intervals. A fresh attempt resets original deadline, but starts new timing intervals. A fresh attempt resets
progress and restarts deployed servers when empty. Recovery cycles repeat progress and restarts deployed servers when empty. Recovery cycles repeat
gameplay milestones and checklist sign-offs while carrying health outages forward. gameplay milestones and checklist sign-offs while carrying health outages forward.
+1 -1
View File
@@ -49,7 +49,7 @@ reported kills and other counters, even when earlier participation is unknown.
Match and player records live in SQLite alongside the rest of Electric Cherry. Match and player records live in SQLite alongside the rest of Electric Cherry.
They persist across app restarts and server deletion, and are not subject to They persist across app restarts and server deletion, and are not subject to
rollout history retention. The latest scoreboard and leaderboard update in the campaign history retention. The latest scoreboard and leaderboard update in the
same transaction. Each match keeps player counters even after someone leaves. same transaction. Each match keeps player counters even after someone leaves.
A `game_start` opens a match; `game_end` completes it. A changed map, a lower round, A `game_start` opens a match; `game_end` completes it. A changed map, a lower round,
+3 -3
View File
@@ -11,10 +11,10 @@ The follow-up [UI/UX review implementation](ui-ux-implementation.md) records the
5. **Server identity:** friendly map names and visible lobby numbers across cards, lists, beta setup, and operations. T6 variants retain their selected map name. Server details provides a copyable raw ID. 5. **Server identity:** friendly map names and visible lobby numbers across cards, lists, beta setup, and operations. T6 variants retain their selected map name. Server details provides a copyable raw ID.
6. **Deployment organization:** Active / Blueprints / History views. Failures and warnings link to filtered history. Destroy fleet lives in Fleet actions and retains its explicit preview. 6. **Deployment organization:** Active / Blueprints / History views. Failures and warnings link to filtered history. Destroy fleet lives in Fleet actions and retains its explicit preview.
7. **Navigation:** Fleet / Library / Operations / Betas / Settings, with section navigation and a mobile menu. Existing route URLs remain usable. 7. **Navigation:** Fleet / Library / Operations / Betas / Settings, with section navigation and a mobile menu. Existing route URLs remain usable.
8. **Status and progress:** shared status labels and tones distinguish ordinary waiting, work in progress, attention, passing tests, and promotion. Deployment, rollout, and beta details share operation history. 8. **Status and progress:** shared status labels and tones distinguish ordinary waiting, work in progress, attention, passing tests, and promotion. Deployment, campaign, and beta details share operation history.
9. **Server detail hub:** live status, current operations, scoreboard, logs, effective configuration, inventory, diagnostics, and scoped rollout history. The scoreboard shows game-reported names, copyable GUIDs, score, kills, headshots, downs, and revives, with sortable stats, map, round, and update time. Final scores remain visible until the next game starts. Missing or stale player counts are unknown. Inventory identifies scripts, plugins, configs, and other files. 9. **Server detail hub:** live status, current operations, scoreboard, logs, effective configuration, inventory, diagnostics, and scoped campaign history. The scoreboard shows game-reported names, copyable GUIDs, score, kills, headshots, downs, and revives, with sortable stats, map, round, and update time. Final scores remain visible until the next game starts. Missing or stale player counts are unknown. Inventory identifies scripts, plugins, configs, and other files.
10. **Dialogs and feedback:** in-app confirmations for deletion, rollback, reconciliation, and unsaved edits. File comparisons use a native modal dialog with Escape and focus restoration. Refresh errors retain loaded data and provide retry controls. 10. **Dialogs and feedback:** in-app confirmations for deletion, rollback, reconciliation, and unsaved edits. File comparisons use a native modal dialog with Escape and focus restoration. Refresh errors retain loaded data and provide retry controls.
11. **Keyboard and forms:** labeled fields, roving keyboard tabs, visible focus, a skip link, and separate navigation/action targets. Removed nested buttons and buttons inside rollout links. 11. **Keyboard and forms:** labeled fields, roving keyboard tabs, visible focus, a skip link, and separate navigation/action targets. Removed nested buttons and buttons inside campaign links.
12. **Readability:** brighter secondary metadata, readable small labels, contrasting primary-button text, and full-opacity offline cards. 12. **Readability:** brighter secondary metadata, readable small labels, contrasting primary-button text, and full-opacity offline cards.
13. **Page states:** compact loading and empty states, actionable failures, and retained snapshots during refresh errors. 13. **Page states:** compact loading and empty states, actionable failures, and retained snapshots during refresh errors.
14. **Mobile:** wrapping identities, scrollable tab/filter rows, compact summaries, responsive forms, and sticky form actions. 14. **Mobile:** wrapping identities, scrollable tab/filter rows, compact summaries, responsive forms, and sticky form actions.
+3 -3
View File
@@ -9,7 +9,7 @@ The changes prioritize clear operational outcomes, reviewed action scope, recove
| Review | Implemented behavior | | 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. | | 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. | | 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 campaign. |
| 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. | | 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. | | 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. | | 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. |
@@ -17,7 +17,7 @@ The changes prioritize clear operational outcomes, reviewed action scope, recove
| 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. | | 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. | | 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. | | 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. | | 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. Campaign 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. | | 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. | | 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. | | 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. |
@@ -31,7 +31,7 @@ The changes prioritize clear operational outcomes, reviewed action scope, recove
| 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. | | 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. | | 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. | | 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. | | 24. Mobile overhead | Fleet avoids redundant headings/zero campaign 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. | | 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. | | 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. |
+10 -10
View File
@@ -8,7 +8,7 @@ Electric Cherry has a coherent visual foundation and unusually capable operation
## Scope and evidence ## 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. 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, campaigns, 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. 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.
@@ -37,7 +37,7 @@ P1 means address first because the issue affects trust, consequential actions, l
| 1 | Label stale player data consistently | P1 | S | | 1 | Label stale player data consistently | P1 | S |
| 2 | Preserve loading-button accessible names | P1 | S | | 2 | Preserve loading-button accessible names | P1 | S |
| 3 | Put beta results and decisions above the event log | P1 | S–M | | 3 | Put beta results and decisions above the event log | P1 | S–M |
| 4 | Preview promotion targets and link the resulting rollout | P1 | M | | 4 | Preview promotion targets and link the resulting campaign | P1 | M |
| 5 | Make reconciliation scope and partial results explicit | P1 | M | | 5 | Make reconciliation scope and partial results explicit | P1 | M |
| 6 | Preserve keys when launch previews refresh | P1 | M | | 6 | Preserve keys when launch previews refresh | P1 | M |
| 7 | Explain blueprint and test-plan validation locally | P1/P2 | S–M | | 7 | Explain blueprint and test-plan validation locally | P1/P2 | S–M |
@@ -59,9 +59,9 @@ Sources: [Fleet page](../src/routes/+page.svelte), lines 56, 62, 208; [ServerCar
### 2. Review the full impact of promotion — P1 · Source ### 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. “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 campaign 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.” 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 campaign**. 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. Acceptance: an operator who tested one lobby can identify the entire affected set and follow remaining restarts without searching another section.
@@ -75,7 +75,7 @@ Separate **Reconcile selected servers** from **Reconcile entire fleet**. Show a
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. 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. Sources: [Campaigns 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 ### 4. Put beta outcome and next action above activity — P1 · Rendered + Source
@@ -128,11 +128,11 @@ Sources: [DestroyServerModal](../src/lib/ui/components/DestroyServerModal.svelte
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. 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. Use structured completion feedback: **7 servers updated, 2 skipped**, with reasons, relevant retry actions, and a campaign 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. 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. Sources: [Campaigns 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 ### 9. Explain scope changes and distinguish eligibility from installation — P1 · Source
@@ -152,7 +152,7 @@ Preserve the accessible label during loading and announce meaningful progress se
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. 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). Sources: [Button](../src/lib/ui/components/primitives/Button.svelte), lines 63–71, 155; [Field](../src/lib/ui/components/primitives/Field.svelte), line 25; [campaign 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 ### 11. Make server detail lead toward the likely next task — P2 · Rendered + Source
@@ -324,7 +324,7 @@ Sources: [LogPanel](../src/lib/ui/components/LogPanel.svelte); [notification sto
## Suggested layout for operational detail pages ## Suggested layout for operational detail pages
Use the same reading order across beta, rollout, and server-deployment details: Use the same reading order across beta, campaign, and server-deployment details:
1. **Outcome and next action:** current state, elapsed/remaining time, blocker, primary recovery or completion action. 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. 2. **Affected servers:** progress counts and a failed/waiting-first list with links to relevant diagnostics.
@@ -337,7 +337,7 @@ This gives the existing shared components a consistent purpose without forcing e
**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. **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. **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 campaign 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. **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.
+2 -2
View File
@@ -31,7 +31,7 @@
?? presets.find(p => JSON.stringify(p.plan) === JSON.stringify(plan))); ?? presets.find(p => JSON.stringify(p.plan) === JSON.stringify(plan)));
const savedPresets = $derived(presets.filter(p => !builtInPlans.some(b => b.name === p.name))); const savedPresets = $derived(presets.filter(p => !builtInPlans.some(b => b.name === p.name)));
const failureSummary = $derived(plan.failureAction === 'fail' ? 'Fail and hold for review' : plan.failureAction === 'pause' ? 'Pause for investigation' : 'Roll back automatically'); const failureSummary = $derived(plan.failureAction === 'fail' ? 'Fail and hold for review' : plan.failureAction === 'pause' ? 'Pause for investigation' : 'Roll back automatically');
const rolloutSummary = $derived(plan.waveSize ? `${plan.waveSize} server${plan.waveSize === 1 ? '' : 's'} per wave` : 'Deploy to all selected servers'); const waveSummary = $derived(plan.waveSize ? `${plan.waveSize} server${plan.waveSize === 1 ? '' : 's'} per wave` : 'Deploy to all selected servers');
async function loadPresets() { async function loadPresets() {
const response = await fetch('/api/betas/presets'); const response = await fetch('/api/betas/presets');
@@ -175,7 +175,7 @@
<div class="policy-settings"> <div class="policy-settings">
<details class="policy" bind:open={coverageOpen}> <details class="policy" bind:open={coverageOpen}>
<summary><span>Coverage &amp; rollout<small>{coverageSummary(plan)} · {rolloutSummary}</small></span><span class="edit-hint">Configure</span></summary> <summary><span>Coverage &amp; waves<small>{coverageSummary(plan)} · {waveSummary}</small></span><span class="edit-hint">Configure</span></summary>
<div class="policy-body"> <div class="policy-body">
<div class="fields"> <div class="fields">
<label>Qualifying servers<select bind:value={plan.scope}> <label>Qualifying servers<select bind:value={plan.scope}>
@@ -32,7 +32,7 @@
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}> <form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
<h2 id="destroy-title">Queue server removal?</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 campaign 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}
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request 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.
+2 -2
View File
@@ -104,7 +104,7 @@
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: 'Campaigns' }]
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'System status' }, { 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>
@@ -144,7 +144,7 @@
<Confirmation /> <Confirmation />
<NotificationHistory bind:open={notificationsOpen} /> <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."> <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> <dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Server deployments'],['g a','Campaigns'],['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> </Dialog>
</div> </div>
+2 -2
View File
@@ -154,7 +154,7 @@
<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" class:no-rollouts={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active rollouts</a> <a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a>
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span> <span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
</div>{/if} </div>{/if}
{#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)} {#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
@@ -282,7 +282,7 @@
.freshness { margin-left: auto; font-size: var(--text-xs); color: var(--text-tertiary); } .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 { 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); } .setup-note strong { color: var(--text-primary); }
@media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-rollouts { display: none; } } @media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-campaigns { 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; }
+4 -4
View File
@@ -31,9 +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);
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 campaign = 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, const promotion = campaign ? { ...campaign,
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, pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state NOT IN ('done','failed','cancelled')").get(campaign.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; failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
return json({ beta, scripts, servers, events, test: testView(id), promotion }); return json({ beta, scripts, servers, events, test: testView(id), promotion });
} }
+1 -1
View File
@@ -42,7 +42,7 @@
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()))); 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 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) { function blocker(beta:BetaListRow) {
if(beta.state==='promoted')return 'Version promoted; check its rollout for remaining activation.'; if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.';
if(beta.state==='rolled-back')return 'Rolled back. The files and plan can be reused for another attempt.'; 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.state==='ready-promote')return 'Ready for promotion review.';
if(beta.testReason)return beta.testReason; if(beta.testReason)return beta.testReason;
+2 -2
View File
@@ -41,7 +41,7 @@
const passed = $derived(test?.servers.filter(server => server.passed).length ?? servers.filter(server => server.stage === 'ready').length); 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 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 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.' const nextAction = $derived(closed ? beta?.state === 'promoted' ? 'Follow the campaign 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 === '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 === '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.' : test?.outcome === 'failed' ? 'Investigate the failed check before a fresh attempt, or roll back.'
@@ -137,7 +137,7 @@
<div class="actions"> <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 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 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} {#if promotion}<Button href={`/campaigns/${encodeURIComponent(promotion.id)}`}>View campaign</Button>{/if}
</div> </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 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 flash}<p role="alert" class="ui-error">{flash}</p>{/if}
+1 -1
View File
@@ -206,7 +206,7 @@
{#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 !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}
{:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} serverCount={pickedServers.size}/> {:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} serverCount={pickedServers.size}/>
{: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} {: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>Waves 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 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> <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>
</form> </form>
{/if} {/if}
+8 -8
View File
@@ -93,7 +93,7 @@
async function loadCampaigns(): Promise<void> { async function loadCampaigns(): Promise<void> {
try { try {
const res = await fetch('/api/campaigns'); const res = await fetch('/api/campaigns');
if (!res.ok) throw new Error('Could not refresh rollouts.'); if (!res.ok) throw new Error('Could not refresh campaigns.');
deployments = (await res.json()).deployments; loadError = ''; deployments = (await res.json()).deployments; loadError = '';
} catch (error) { loadError = (error as Error).message; } } catch (error) { loadError = (error as Error).message; }
finally { loading = false; } finally { loading = false; }
@@ -225,12 +225,12 @@
); );
</script> </script>
<svelte:head><title>Rollouts | Electric Cherry</title></svelte:head> <svelte:head><title>Campaigns | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader <SectionHeader
title="Rollouts" title="Campaigns"
subtitle="Track file rollouts, reconcile drift, and review rollbacks." subtitle="Track file campaigns, reconcile drift, and review rollbacks."
/> />
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={loadCampaigns}>Retry</Button></div>{/if} {#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={loadCampaigns}>Retry</Button></div>{/if}
@@ -325,7 +325,7 @@
{#if result}<div class="result" class:result-error={skipped.length > 0} role={skipped.length ? 'alert' : 'status'}> {#if result}<div class="result" class:result-error={skipped.length > 0} role={skipped.length ? 'alert' : 'status'}>
<strong>{result.totalServers} server{result.totalServers === 1 ? '' : 's'} updated{skipped.length ? `, ${skipped.length} with skipped files` : ''}</strong> <strong>{result.totalServers} server{result.totalServers === 1 ? '' : 's'} updated{skipped.length ? `, ${skipped.length} with skipped files` : ''}</strong>
<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> <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} {#if result.deploymentId}<a href="/campaigns/{result.deploymentId}">View campaign →</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} {#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} {#if skipped.length}<Button onclick={() => reviewDrift(false, skipped.map(server => server.server))}>Review unresolved servers</Button>{/if}
</div>{/if} </div>{/if}
@@ -359,7 +359,7 @@
</div> </div>
{#if history.length === 0} {#if history.length === 0}
<EmptyState <EmptyState
title="No completed rollouts in the retention window" title="No completed campaigns in the retention window"
description="Retention is 7 days by default." description="Retention is 7 days by default."
/> />
{:else} {:else}
@@ -375,7 +375,7 @@
{/each} {/each}
{/if} {/if}
</section> </section>
<ListFooter total={history.length} bind:limit onchange={saveContext} label="rollouts" /> <ListFooter total={history.length} bind:limit onchange={saveContext} label="campaigns" />
{/if} {/if}
</div> </div>
@@ -396,7 +396,7 @@
{#if flash && flashId === d.id}<div class="result" class:result-error={flashError} role={flashError ? 'alert' : 'status'}>{flash}</div>{/if} {#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} campaign <span class="sr-only">{d.id}</span></a>
{#if d.action === 'promote' || d.action === 'rollback'} {#if d.action === 'promote' || d.action === 'rollback'}
<span class="target">{scriptSummary(d)}</span> <span class="target">{scriptSummary(d)}</span>
{/if} {/if}
+6 -6
View File
@@ -57,7 +57,7 @@
const current = ++request; const current = ++request;
try { try {
const res = await fetch(`/api/campaigns/${encodeURIComponent(id ?? '')}`); const res = await fetch(`/api/campaigns/${encodeURIComponent(id ?? '')}`);
if (!res.ok) throw new Error(res.status === 404 ? 'Rollout not found.' : 'Could not refresh rollout.'); if (!res.ok) throw new Error(res.status === 404 ? 'Campaign not found.' : 'Could not refresh campaign.');
const body = await res.json() as Detail; const body = await res.json() as Detail;
if (current !== request || id !== $page.params.id) return; if (current !== request || id !== $page.params.id) return;
detail = body; loadError = ''; detail = body; loadError = '';
@@ -109,22 +109,22 @@
} }
</script> </script>
<svelte:head><title>{detail ? `${detail.deployment.action} rollout · ${detail.deployment.id}` : 'Rollout'} | Electric Cherry</title></svelte:head> <svelte:head><title>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | 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.charAt(0).toUpperCase() + action.slice(1)} rollout`} subtitle={detail.deployment.id}> <SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} campaign`} subtitle={detail.deployment.id}>
{#snippet crumbs()} {#snippet crumbs()}
<Breadcrumbs items={[ <Breadcrumbs items={[
{ label: 'Rollouts', href: '/campaigns' }, { label: 'Campaigns', href: '/campaigns' },
{ label: action } { label: action }
]} /> ]} />
{/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> <section class="outcome" aria-label="Campaign 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">
@@ -171,7 +171,7 @@
<Badge variant="neutral" size="sm">{detail.files.length}</Badge> <Badge variant="neutral" size="sm">{detail.files.length}</Badge>
</div> </div>
{#if detail.files.length === 0} {#if detail.files.length === 0}
<EmptyState title="No file changes recorded" description="This rollout didn't change any tracked files." /> <EmptyState title="No file changes recorded" description="This campaign didn't change any tracked files." />
{:else} {:else}
{#each [...byServer.entries()] as [srv, files] (srv)} {#each [...byServer.entries()] as [srv, files] (srv)}
<div class="file-group"> <div class="file-group">
+3 -3
View File
@@ -522,12 +522,12 @@
{/if} {/if}
<Card padding="lg"> <Card padding="lg">
<div class="panel-head"> <div class="panel-head">
<h2>Rollout history</h2> <h2>Campaign history</h2>
<span class="panel-count">{history.length}</span> <span class="panel-count">{history.length}</span>
</div> </div>
{#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0} {#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0}
<EmptyState <EmptyState
title="No rollouts yet" title="No campaigns yet"
description="Nothing has been pushed to this server. Open a beta or push scripts to see history here." description="Nothing has been pushed to this server. Open a beta or push scripts to see history here."
/> />
{:else} {:else}
@@ -550,7 +550,7 @@
</div> </div>
{/if} {/if}
</Card> </Card>
<ListFooter total={history.length} bind:limit={historyLimit} label="rollouts" /> <ListFooter total={history.length} bind:limit={historyLimit} label="campaigns" />
{/if} {/if}
</div> </div>
</div> </div>
+2 -2
View File
@@ -29,7 +29,7 @@ async function ready(kind: 'script' | 'baseline' = 'script', scope: 'game' | 'ma
const destination = () => join(fixture.t5, servers[1], 'runtime/plutonium/storage/t5/raw/scripts/sp/review.gsc'); const destination = () => join(fixture.t5, servers[1], 'runtime/plutonium/storage/t5/raw/scripts/sp/review.gsc');
describe('reviewed beta promotion', () => { describe('reviewed beta promotion', () => {
it('previews exact versions and additional targets without writing, then links the activation rollout', async () => { it('previews exact versions and additional targets without writing, then links the activation campaign', async () => {
const { betaId, script, version } = await ready(); const { betaId, script, version } = await ready();
const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js'); const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const preview = previewBetaPromotion(betaId); const preview = previewBetaPromotion(betaId);
@@ -42,7 +42,7 @@ describe('reviewed beta promotion', () => {
expect(result.deploymentId).toContain('promote'); expect(result.deploymentId).toContain('promote');
expect(await readFile(destination(), 'utf8')).toBe('main(){}'); expect(await readFile(destination(), 'utf8')).toBe('main(){}');
}); });
it('rejects changed scope membership before creating a rollout or writing extra files', async () => { it('rejects changed scope membership before creating a campaign or writing extra files', async () => {
const { betaId } = await ready('script', 'map'); const { betaId } = await ready('script', 'map');
const { previewBetaPromotion, promoteBeta, getBeta } = await import('../../src/lib/server/fleet/beta.js'); const { previewBetaPromotion, promoteBeta, getBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
+1 -1
View File
@@ -6,7 +6,7 @@ beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); }); afterEach(async () => { await fixture.cleanup(); });
describe('server detail data', () => { describe('server detail data', () => {
it('filters history before the global limit and includes the matching rollout members', async () => { it('filters history before the global limit and includes the matching campaign members', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const handle = db(); const handle = db();
handle.exec("INSERT INTO servers (name, komodo_name) VALUES ('target', 'target'), ('other', 'other')"); handle.exec("INSERT INTO servers (name, komodo_name) VALUES ('target', 'target'), ('other', 'other')");