Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b65520bbc | ||
|
|
3c0c54e804 | ||
|
|
905d1aa3f6 |
@@ -171,7 +171,7 @@ game-status, and selected-map verification.
|
|||||||
No token, no session, no auth freeze. If Komodo or RCON aren't configured,
|
No token, no session, no auth freeze. If Komodo or RCON aren't configured,
|
||||||
the sweep marks pending rows `manual` so nothing hangs forever.
|
the sweep marks pending rows `manual` so nothing hangs forever.
|
||||||
|
|
||||||
## Provisioning collections and launch previews
|
## Collections, provisioning, and launch previews
|
||||||
|
|
||||||
Provisioning also offers **House Party Protocol**: review the entire fleet and queue
|
Provisioning also offers **House Party Protocol**: review the entire fleet and queue
|
||||||
destruction in one action. This includes queued creations and reuses pending
|
destruction in one action. This includes queued creations and reuses pending
|
||||||
@@ -189,7 +189,12 @@ repeating Komodo deletion. Requesting destruction again also resumes that cleanu
|
|||||||
Recovery is blocked if a newer operation exists for the same server name.
|
Recovery is blocked if a newer operation exists for the same server name.
|
||||||
|
|
||||||
**Operations → Collections** includes a builder for whole server lineups: choose
|
**Operations → Collections** includes a builder for whole server lineups: choose
|
||||||
multiple T5/T6 maps and set the desired total lobbies for each. **Conform fleet**
|
multiple T5/T6 maps and set the desired total lobbies for each. Collections also
|
||||||
|
has **Active launches** and **Launch history** tabs, with progress at
|
||||||
|
`/collections/launches/:id`.
|
||||||
|
Each server operation remains in Provisioning and links back to its collection
|
||||||
|
launch. Existing collection launch bookmarks redirect to the new location.
|
||||||
|
**Conform fleet**
|
||||||
counts existing and queued servers and creates only the shortfall. The preview
|
counts existing and queued servers and creates only the shortfall. The preview
|
||||||
shows target/existing/missing counts and IDs, ports, and key requirements for new
|
shows target/existing/missing counts and IDs, ports, and key requirements for new
|
||||||
servers only. Already-satisfied targets create nothing; excess servers are kept.
|
servers only. Already-satisfied targets create nothing; excess servers are kept.
|
||||||
@@ -230,7 +235,9 @@ During deployment, choose the map. The lowest available lobby is assigned
|
|||||||
automatically, with the exact server ID shown before deployment. Its saved key is reused automatically,
|
automatically, with the exact server ID shown before deployment. Its saved key is reused automatically,
|
||||||
and any newly entered key is saved for that ID, including if deployment fails.
|
and any newly entered key is saved for that ID, including if deployment fails.
|
||||||
Destroying a server preserves its saved key. Updating or removing a saved key
|
Destroying a server preserves its saved key. Updating or removing a saved key
|
||||||
does not change a running server; changes apply to future deployments.
|
does not change an existing server; changes apply when that server ID is next
|
||||||
|
created or rebuilt. Electric Cherry does not check whether Plutonium accepts a
|
||||||
|
saved key. **Last saved** shows when the key was saved, not a validation result.
|
||||||
|
|
||||||
Keys are stored in the application's SQLite database at `DB_PATH`, independently
|
Keys are stored in the application's SQLite database at `DB_PATH`, independently
|
||||||
of server records. Preserve that database across container replacements. Key
|
of server records. Preserve that database across container replacements. Key
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Deployment collections
|
# Deployment collections
|
||||||
|
|
||||||
Open **Operations → Collections** to manage reusable server lineups. A
|
Open **Operations → Collections** to manage reusable server lineups. Use
|
||||||
|
**Active launches** to follow progress and **Launch history** to review completed
|
||||||
|
launches and failures. Both views refresh automatically. A
|
||||||
collection describes a whole lineup: for example, two Kino lobbies, one Moon lobby,
|
collection describes a whole lineup: for example, two Kino lobbies, one Moon lobby,
|
||||||
and two Town lobbies. Counts are desired fleet totals: existing and queued
|
and two Town lobbies. Counts are desired fleet totals: existing and queued
|
||||||
servers count toward each target, and only missing servers are created.
|
servers count toward each target, and only missing servers are created.
|
||||||
@@ -105,11 +107,13 @@ A lost startup response also keeps the slot because the container may already be
|
|||||||
running. A confirmed creation failure is recorded for review, allowing later
|
running. A confirmed creation failure is recorded for review, allowing later
|
||||||
queued servers to proceed on subsequent passes without undoing successful launches.
|
queued servers to proceed on subsequent passes without undoing successful launches.
|
||||||
|
|
||||||
The collection page separates ready, launching, queued, and failed servers. It
|
The collection launch page at `/collections/launches/:id` separates ready,
|
||||||
|
launching, queued, and failed servers. Its breadcrumb returns to Collections. It
|
||||||
highlights the current startup, shows each waiting server's queue position, and
|
highlights the current startup, shows each waiting server's queue position, and
|
||||||
links to earlier launches when another collection occupies the available capacity.
|
links to earlier launches when another collection occupies the available capacity.
|
||||||
Every server links to its operation timeline and recovery options, and each child
|
Every server links to its Provisioning operation timeline and recovery options,
|
||||||
operation links back to its collection.
|
and each child operation links back to its collection launch. Individual operations
|
||||||
|
remain visible in Provisioning activity and history.
|
||||||
|
|
||||||
## Create and review a launch
|
## Create and review a launch
|
||||||
|
|
||||||
@@ -204,6 +208,9 @@ repeats container creation or deployment as part of this recovery.
|
|||||||
Collections retain the existing `/api/blueprints` endpoints and `blueprintId` /
|
Collections retain the existing `/api/blueprints` endpoints and `blueprintId` /
|
||||||
`blueprintName` fields for compatibility. Saved data needs no migration. Old
|
`blueprintName` fields for compatibility. Saved data needs no migration. Old
|
||||||
`/provisioning?view=blueprints` links redirect to `/collections`.
|
`/provisioning?view=blueprints` links redirect to `/collections`.
|
||||||
|
Collection launch details live at `/collections/launches/:id`; old
|
||||||
|
`/provisioning/collections/:id` and `/deployments/collections/:id` links redirect
|
||||||
|
there while preserving query parameters.
|
||||||
|
|
||||||
- `GET /api/blueprints`: list lineup summaries with game/map selections and counts.
|
- `GET /api/blueprints`: list lineup summaries with game/map selections and counts.
|
||||||
- `POST /api/blueprints`: save `{ name, description?, previewId }` or
|
- `POST /api/blueprints`: save `{ name, description?, previewId }` or
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Provisioning
|
# Provisioning
|
||||||
|
|
||||||
Open **Operations → Provisioning** for instance lifecycle work, blueprints, and
|
Open **Operations → Provisioning** for instance lifecycle work and persistent
|
||||||
persistent activity. Provisioning owns the queues, safety checks, execution, and
|
activity. **Operations → Collections** owns reusable lineups and collection launch
|
||||||
|
progress; their individual operations remain visible in Provisioning.
|
||||||
|
Provisioning owns the queues, safety checks, execution, and
|
||||||
recovery for all six actions. Create a server using **Create server**. For an existing
|
recovery for all six actions. Create a server using **Create server**. For an existing
|
||||||
server, choose **Manage server** or open its **Server actions** menu.
|
server, choose **Manage server** or open its **Server actions** menu.
|
||||||
|
|
||||||
@@ -75,9 +77,10 @@ requester closes. Upgrades adopt existing restart intent for observation only.
|
|||||||
|
|
||||||
## Routes and compatibility
|
## Routes and compatibility
|
||||||
|
|
||||||
- `/provisioning`: active operations, blueprints, and history filtered by action.
|
- `/provisioning`: active operations and history filtered by action.
|
||||||
- `/provisioning/:id`: operation stages, errors, timestamps, and activity.
|
- `/provisioning/:id`: operation stages, errors, timestamps, and activity.
|
||||||
- `/provisioning/collections/:id`: collection progress.
|
- `/collections`: saved lineups, active launches, and launch history.
|
||||||
|
- `/collections/launches/:id`: collection progress, with links to each operation.
|
||||||
- `GET /api/provisioning`: lifecycle history (`deployments` response property).
|
- `GET /api/provisioning`: lifecycle history (`deployments` response property).
|
||||||
- `POST /api/provisioning`: queue `{ "server": "t5_zombie_theater_1", "action": "start" }`.
|
- `POST /api/provisioning`: queue `{ "server": "t5_zombie_theater_1", "action": "start" }`.
|
||||||
Supported actions are `start`, `restart`, `rebuild`, and `stop`. Returns HTTP 202
|
Supported actions are `start`, `restart`, `rebuild`, and `stop`. Returns HTTP 202
|
||||||
@@ -88,6 +91,8 @@ requester closes. Upgrades adopt existing restart intent for observation only.
|
|||||||
launch preview, and fleet destruction APIs.
|
launch preview, and fleet destruction APIs.
|
||||||
|
|
||||||
Old `/deployments` pages redirect to their `/provisioning` equivalents, preserving
|
Old `/deployments` pages redirect to their `/provisioning` equivalents, preserving
|
||||||
|
query parameters. Collection launch links at `/provisioning/collections/:id` or
|
||||||
|
`/deployments/collections/:id` redirect to `/collections/launches/:id` and preserve
|
||||||
query parameters. `/api/deployments` remains compatible, including old campaign
|
query parameters. `/api/deployments` remains compatible, including old campaign
|
||||||
detail endpoints. No history or IDs are renamed or discarded.
|
detail endpoints. No history or IDs are renamed or discarded.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { komodoInspectHealthy, komodoRestart, komodoRuntimeAction } from '../int
|
|||||||
import { probeServerStatus } from '../fleet/status-evidence.js';
|
import { probeServerStatus } from '../fleet/status-evidence.js';
|
||||||
import { deploymentStage, finishDeployment } from './history.js';
|
import { deploymentStage, finishDeployment } from './history.js';
|
||||||
import { applyStagedFilesForOperation } from '../fleet/staged-files.js';
|
import { applyStagedFilesForOperation } from '../fleet/staged-files.js';
|
||||||
|
import type { RuntimeAction } from '../../provisioning.js';
|
||||||
|
|
||||||
// Komodo queues restart work. Don't mistake the still-running old container
|
// Komodo queues restart work. Don't mistake the still-running old container
|
||||||
// for a completed restart when an API kick immediately follows the request.
|
// for a completed restart when an API kick immediately follows the request.
|
||||||
@@ -36,7 +37,7 @@ export function restartSettling(server: string): boolean {
|
|||||||
&& Date.now() - Date.parse(slot.requested_at) < HEALTH_POLL_MS);
|
&& Date.now() - Date.parse(slot.requested_at) < HEALTH_POLL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rolloutWaitReason(server: string, operationId = ''): string | null {
|
export function rolloutWaitReason(server: string, operationId = '', action: RuntimeAction = 'restart'): string | null {
|
||||||
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
|
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
|
||||||
AND ((action IN ('start','stop','rebuild') AND state IN ('running','awaiting-health'))
|
AND ((action IN ('start','stop','rebuild') AND state IN ('running','awaiting-health'))
|
||||||
OR (action = 'destroy' AND state IN ('waiting','running')))`).get(server, operationId)) {
|
OR (action = 'destroy' AND state IN ('waiting','running')))`).get(server, operationId)) {
|
||||||
@@ -47,10 +48,11 @@ export function rolloutWaitReason(server: string, operationId = ''): string | nu
|
|||||||
WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.action IN ('restart','rebuild') AND EXISTS (
|
WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.action IN ('restart','rebuild') AND EXISTS (
|
||||||
SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'watchdog')`).get(operationId);
|
SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'watchdog')`).get(operationId);
|
||||||
if (slot?.healthy_at === null && !ownHandoff) {
|
if (slot?.healthy_at === null && !ownHandoff) {
|
||||||
return `Waiting for the previous restart of this server to recover${slot.error ? `: ${slot.error}` : ''}`;
|
return `Waiting for this server to recover from its previous operation${slot.error ? `: ${slot.error}` : ''}`;
|
||||||
}
|
}
|
||||||
const { n } = db().prepare('SELECT COUNT(*) AS n FROM restart_rollout WHERE healthy_at IS NULL').get() as { n: number };
|
const { n } = db().prepare('SELECT COUNT(*) AS n FROM restart_rollout WHERE healthy_at IS NULL').get() as { n: number };
|
||||||
return n - (ownHandoff && slot?.healthy_at === null ? 1 : 0) >= fleetSettings().restartConcurrency ? 'Queued for a rolling restart; waiting for another server to recover' : null;
|
return n - (ownHandoff && slot?.healthy_at === null ? 1 : 0) >= fleetSettings().restartConcurrency
|
||||||
|
? `Queued to ${action}; waiting for another server to recover` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RolloutRestartResult =
|
export type RolloutRestartResult =
|
||||||
@@ -63,7 +65,7 @@ export async function restartInRollout(server: string, options: { operationId: s
|
|||||||
const operationId = options.operationId;
|
const operationId = options.operationId;
|
||||||
const waitReason = db().transaction(() => {
|
const waitReason = db().transaction(() => {
|
||||||
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL').get(operationId)) return 'Original server was deleted';
|
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL').get(operationId)) return 'Original server was deleted';
|
||||||
const reason = rolloutWaitReason(server, operationId);
|
const reason = rolloutWaitReason(server, operationId, options.action);
|
||||||
if (reason) return reason;
|
if (reason) return reason;
|
||||||
db().prepare(`INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)
|
db().prepare(`INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(server) DO UPDATE SET requested_at = excluded.requested_at,
|
ON CONFLICT(server) DO UPDATE SET requested_at = excluded.requested_at,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function sourceEligibility(operation: Operation): 'ready' | 'held' | 'cancelled'
|
|||||||
return held ? 'held' : ready ? 'ready' : 'cancelled';
|
return held ? 'held' : ready ? 'ready' : 'cancelled';
|
||||||
}
|
}
|
||||||
|
|
||||||
function blocker(server: string, id: string): string | null {
|
function blocker(server: string, id: string, action: RuntimeAction): string | null {
|
||||||
const sources = sourcesFor(id);
|
const sources = sourcesFor(id);
|
||||||
const rank = priority(sources);
|
const rank = priority(sources);
|
||||||
const others = db().prepare(`SELECT id, state, action, rowid FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
|
const others = db().prepare(`SELECT id, state, action, rowid FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
|
||||||
@@ -76,7 +76,7 @@ function blocker(server: string, id: string): string | null {
|
|||||||
SELECT 1 FROM server_deployments d WHERE d.id = ds.provisioning_id AND d.state IN ('queued','waiting','running','awaiting-health')))
|
SELECT 1 FROM server_deployments d WHERE d.id = ds.provisioning_id AND d.state IN ('queued','waiting','running','awaiting-health')))
|
||||||
AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.operation_id = ? AND a.campaign_id = ds.deployment_id)
|
AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.operation_id = ? AND a.campaign_id = ds.deployment_id)
|
||||||
LIMIT 1`).get(server, id)) return 'Waiting for campaign work on this server';
|
LIMIT 1`).get(server, id)) return 'Waiting for campaign work on this server';
|
||||||
return rolloutWaitReason(server, id);
|
return rolloutWaitReason(server, id, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Operation { id: string; server: string; action: RuntimeAction; state: string; stage: string }
|
interface Operation { id: string; server: string; action: RuntimeAction; state: string; stage: string }
|
||||||
@@ -128,7 +128,7 @@ async function processOperation(operation: Operation): Promise<void> {
|
|||||||
} else deploymentStage(id, 'Waiting for container to stop' + (!health.ok ? ': ' + health.reason : ''));
|
} else deploymentStage(id, 'Waiting for container to stop' + (!health.ok ? ': ' + health.reason : ''));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reason = blocker(server, id);
|
const reason = blocker(server, id, action);
|
||||||
if (reason) { deploymentStage(id, reason); return; }
|
if (reason) { deploymentStage(id, reason); return; }
|
||||||
const health = await komodoInspectHealthy(server);
|
const health = await komodoInspectHealthy(server);
|
||||||
if (action !== 'start' && !isStopped(health.state)) {
|
if (action !== 'start' && !isStopped(health.state)) {
|
||||||
@@ -140,7 +140,7 @@ async function processOperation(operation: Operation): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Requests can arrive during the probes. Recheck before claiming the action.
|
// Requests can arrive during the probes. Recheck before claiming the action.
|
||||||
const latestReason = blocker(server, id);
|
const latestReason = blocker(server, id, action);
|
||||||
if (latestReason) { deploymentStage(id, latestReason); return; }
|
if (latestReason) { deploymentStage(id, latestReason); return; }
|
||||||
if (sourceEligibility(operation) !== 'ready') return;
|
if (sourceEligibility(operation) !== 'ready') return;
|
||||||
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").get(id)) return;
|
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").get(id)) return;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
|
if (!res.ok) throw new Error(body.error ?? 'Could not queue the collection.');
|
||||||
open = false;
|
open = false;
|
||||||
if (body.collectionId) await goto(`/provisioning/collections/${encodeURIComponent(body.collectionId)}`);
|
if (body.collectionId) await goto(`/collections/launches/${encodeURIComponent(body.collectionId)}`);
|
||||||
} catch (err) { error = (err as Error).message; }
|
} catch (err) { error = (err as Error).message; }
|
||||||
finally { busy = false; }
|
finally { busy = false; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
<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>
|
||||||
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
|
{#if preview.hasSavedKey}<p class="key-ready"><span aria-hidden="true">✓</span> Saved key ready</p>
|
||||||
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
|
{:else}<label>Plutonium server key<input type="password" bind:value={plutoKey} disabled={busy || saving || !preview.name} maxlength="512" autocomplete="off" placeholder="Paste the key for this server ID" /></label>{/if}
|
||||||
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Its validity is checked when the game starts. Update it in Keys.' : '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>
|
<p class="muted">{preview.hasSavedKey ? 'The saved key for this server ID will be reused automatically. Electric Cherry does not check whether Plutonium accepts it. Update it in Keys.' : '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 || expired || !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}
|
{#if !preview.hasSavedKey && !plutoKey.trim()}<p class="muted">Enter this server’s key to enable launch.</p>{/if}
|
||||||
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
|
<p class="caption">Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Lobby and port availability are checked again at launch.</p>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const states: Record<string, [string, StatusTone]> = {
|
|||||||
queued: ['Queued', 'neutral'], pending: ['Queued for restart', 'neutral'], running: ['In progress', 'info'], active: ['In progress', 'info'],
|
queued: ['Queued', 'neutral'], pending: ['Queued for restart', 'neutral'], running: ['In progress', 'info'], active: ['In progress', 'info'],
|
||||||
deploying: ['Installing files', 'info'], restarting: ['Restarting', 'info'],
|
deploying: ['Installing files', 'info'], restarting: ['Restarting', 'info'],
|
||||||
observing: ['Collecting test evidence', 'info'], 'rolling-back': ['Rolling back', 'info'],
|
observing: ['Collecting test evidence', 'info'], 'rolling-back': ['Rolling back', 'info'],
|
||||||
'awaiting-empty': ['Waiting for empty lobby', 'neutral'], waiting: ['Waiting for empty lobby', 'neutral'],
|
'awaiting-empty': ['Waiting for empty lobby', 'neutral'], waiting: ['Waiting', 'neutral'],
|
||||||
'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'],
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
|
import { pollVisible } from '$ui/poll.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||||
|
import Tabs from '$ui/components/primitives/Tabs.svelte';
|
||||||
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
|
import BlueprintBuilder from '$ui/components/BlueprintBuilder.svelte';
|
||||||
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
|
import DeployCollectionDialog from '$ui/components/DeployCollectionDialog.svelte';
|
||||||
import type { BlueprintSummary } from '$lib/deployment-plan.js';
|
import type { BlueprintSummary, CollectionSummary } from '$lib/deployment-plan.js';
|
||||||
let blueprintId = $state('');
|
let blueprintId = $state('');
|
||||||
let builderOpen = $state(false);
|
let builderOpen = $state(false);
|
||||||
let editingBlueprint = $state<BlueprintSummary | null>(null);
|
let editingBlueprint = $state<BlueprintSummary | null>(null);
|
||||||
@@ -17,14 +22,38 @@
|
|||||||
let blueprintsLoading = $state(true);
|
let blueprintsLoading = $state(true);
|
||||||
let blueprintError = $state('');
|
let blueprintError = $state('');
|
||||||
let removing = $state('');
|
let removing = $state('');
|
||||||
|
let launches = $state<CollectionSummary[]>([]);
|
||||||
|
let launchesLoading = $state(true);
|
||||||
|
let launchError = $state('');
|
||||||
|
let launchesRefreshing = false;
|
||||||
|
let tab = $state('collections');
|
||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
$effect(() => { limit = Math.max(25, Number($page.url.searchParams.get('limit')) || 25); });
|
let launchLimit = $state(10);
|
||||||
|
const activeLaunches = $derived(launches.filter(launch => launch.active > 0));
|
||||||
|
const launchHistory = $derived(launches.filter(launch => launch.active === 0));
|
||||||
|
const visibleLaunches = $derived(tab === 'active' ? activeLaunches : launchHistory);
|
||||||
|
const tabs = $derived([
|
||||||
|
{ id: 'collections', label: 'Your collections' },
|
||||||
|
{ id: 'active', label: 'Active launches', badge: launchesLoading ? undefined : activeLaunches.length },
|
||||||
|
{ id: 'history', label: 'Launch history' }
|
||||||
|
]);
|
||||||
|
$effect(() => {
|
||||||
|
const params = $page.url.searchParams;
|
||||||
|
tab = ['active', 'history'].includes(params.get('view') ?? '') ? params.get('view')! : 'collections';
|
||||||
|
limit = Math.max(25, Number(params.get('limit')) || 25);
|
||||||
|
launchLimit = Math.max(10, Number(params.get('launches')) || 10);
|
||||||
|
});
|
||||||
function saveContext() {
|
function saveContext() {
|
||||||
const url = new URL($page.url);
|
const url = new URL($page.url);
|
||||||
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
|
if (limit > 25) url.searchParams.set('limit', String(limit)); else url.searchParams.delete('limit');
|
||||||
|
if (tab !== 'collections') url.searchParams.set('view', tab); else url.searchParams.delete('view');
|
||||||
|
if (launchLimit > 10) url.searchParams.set('launches', String(launchLimit)); else url.searchParams.delete('launches');
|
||||||
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
}
|
}
|
||||||
onMount(() => { void loadBlueprints(); });
|
onMount(() => {
|
||||||
|
void loadBlueprints();
|
||||||
|
return pollVisible(loadLaunches, 5000);
|
||||||
|
});
|
||||||
function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
|
function blueprintSaved(blueprint: BlueprintSummary, launch: boolean) {
|
||||||
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
|
blueprints = [...blueprints.filter(item => item.id !== blueprint.id), blueprint].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
|
if (launch) { blueprintId = blueprint.id; collectionOpen = true; }
|
||||||
@@ -38,6 +67,17 @@
|
|||||||
} catch (err) { blueprintError = (err as Error).message; }
|
} catch (err) { blueprintError = (err as Error).message; }
|
||||||
finally { blueprintsLoading = false; }
|
finally { blueprintsLoading = false; }
|
||||||
}
|
}
|
||||||
|
async function loadLaunches() {
|
||||||
|
if (launchesRefreshing) return;
|
||||||
|
launchesRefreshing = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/deployment-collections');
|
||||||
|
if (!res.ok) throw new Error('Could not load collection launches.');
|
||||||
|
launches = (await res.json()).collections;
|
||||||
|
launchError = '';
|
||||||
|
} catch (err) { launchError = (err as Error).message; }
|
||||||
|
finally { launchesLoading = false; launchesRefreshing = false; }
|
||||||
|
}
|
||||||
async function removeBlueprint(id: string) {
|
async function removeBlueprint(id: string) {
|
||||||
if (!await confirmAction({title:'Remove collection?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove collection', danger:true})) return;
|
if (!await confirmAction({title:'Remove collection?', message:'Existing servers and launch history will be kept.', confirmLabel:'Remove collection', danger:true})) return;
|
||||||
removing = id; blueprintError = '';
|
removing = id; blueprintError = '';
|
||||||
@@ -51,10 +91,14 @@
|
|||||||
</script>
|
</script>
|
||||||
<svelte:head><title>Collections | Electric Cherry</title></svelte:head>
|
<svelte:head><title>Collections | Electric Cherry</title></svelte:head>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Collections" subtitle="Save your lineup of maps and lobby counts, then preview changes to your fleet." />
|
<SectionHeader title="Collections" subtitle="Save your lineup, preview fleet changes, and follow launch progress and history.">
|
||||||
|
{#snippet actions()}<Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button>{/snippet}
|
||||||
|
</SectionHeader>
|
||||||
<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} />
|
||||||
<div class="ui-actions"><Button variant="primary" onclick={() => { editingBlueprint = null; builderOpen = true; }}>Create collection</Button></div>
|
<Tabs {tabs} bind:value={tab} onchange={() => { launchLimit = 10; saveContext(); }} ariaLabel="Collection views" idPrefix="collection-tabs" panelId="collection-panel" />
|
||||||
|
<div id="collection-panel" role="tabpanel" aria-labelledby={"collection-tabs-" + tab} class="panel">
|
||||||
|
{#if tab === 'collections'}
|
||||||
<section>
|
<section>
|
||||||
<h2>Your collections <span>{blueprints.length}</span></h2>
|
<h2>Your collections <span>{blueprints.length}</span></h2>
|
||||||
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
{#if blueprintError}<p role="alert" class="error">{blueprintError} <button onclick={loadBlueprints}>Retry</button></p>{/if}
|
||||||
@@ -82,13 +126,46 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="collections" />
|
<ListFooter total={blueprints.length} bind:limit onchange={() => saveContext()} label="collections" />
|
||||||
|
{:else}
|
||||||
|
<section>
|
||||||
|
<h2>{tab === 'active' ? 'Collection launches' : 'Launch history'} <span>{visibleLaunches.length}</span></h2>
|
||||||
|
{#if launchError}<p role="alert" class="error">{launchError} <button onclick={loadLaunches}>Retry</button></p>{/if}
|
||||||
|
{#if launchesLoading}<p>Loading collection launches…</p>
|
||||||
|
{:else if visibleLaunches.length}
|
||||||
|
<div class="launches">{#each visibleLaunches.slice(0,launchLimit) as collection (collection.id)}
|
||||||
|
<a class="collection-row" href="/collections/launches/{collection.id}">
|
||||||
|
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.active ? 'info' : collection.failed ? 'danger' : 'online'}>{collection.active ? collection.provisioning + collection.verifying ? collection.mode === 'exact' ? 'Applying collection' : 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
|
||||||
|
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}</div>
|
||||||
|
<div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span></div>
|
||||||
|
<div class="collection-footer"><span class="meta">Queued {$timeFormat.dateTime(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow progress' : 'View results'} →</span></div>
|
||||||
|
</a>
|
||||||
|
{/each}</div>
|
||||||
|
{:else if !launchError}
|
||||||
|
<EmptyState title={tab === 'active' ? 'No collection launches in flight' : 'No collection launch history'} description={tab === 'active' ? 'Preview fleet changes from Your collections to start a launch.' : 'Completed launches and launches needing attention appear here.'} />
|
||||||
|
{/if}
|
||||||
|
<ListFooter total={visibleLaunches.length} bind:limit={launchLimit} onchange={() => saveContext()} step={10} label="launches" />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
.page { display: flex; flex-direction: column; gap: 28px; }
|
.page { display: flex; flex-direction: column; gap: 28px; }
|
||||||
|
.panel { display: grid; gap: 16px; }
|
||||||
section { display: grid; gap: 16px; }
|
section { display: grid; gap: 16px; }
|
||||||
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; }
|
||||||
p { margin: 8px 0; }
|
p { margin: 8px 0; }
|
||||||
.error { color: var(--state-danger); }
|
.error { color: var(--state-danger); }
|
||||||
|
.launches { display: grid; gap: 12px; }
|
||||||
|
.collection-row { display: grid; gap: 16px; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
|
||||||
|
.collection-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
|
||||||
|
.collection-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||||
|
.row-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: 13px; color: var(--text-secondary); }
|
||||||
|
.collection-counts strong { color: var(--state-online); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.collection-meter { display: flex; height: 4px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }
|
||||||
|
.collection-meter .verified { background: var(--state-online); }.collection-meter .unsuccessful { background: var(--state-danger); }
|
||||||
|
.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 12px; }
|
||||||
|
.meta { color: var(--text-secondary); }.collection-link { color: var(--accent-primary-hover); }
|
||||||
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
.blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||||
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
|
.blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; }
|
||||||
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
|
.eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; }
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getCollection } from '$lib/server/fleet/collections.js';
|
||||||
|
|
||||||
|
export function load({ params }: { params: { id: string } }) {
|
||||||
|
if (!getCollection(params.id)) error(404, 'Collection not found.');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
+2
-2
@@ -20,7 +20,7 @@
|
|||||||
const currentServers = $derived(detail?.servers.filter(launching) ?? []);
|
const currentServers = $derived(detail?.servers.filter(launching) ?? []);
|
||||||
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
|
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
|
||||||
const firstQueued = $derived(detail?.servers.find(server => !server.server_deleted_at && server.state === 'queued'));
|
const firstQueued = $derived(detail?.servers.find(server => !server.server_deleted_at && server.state === 'queued'));
|
||||||
const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting for empty server', cancelled: 'Cancelled' };
|
const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting', cancelled: 'Cancelled' };
|
||||||
const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port'));
|
const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port'));
|
||||||
function stateLabel(server: CollectionServer) {
|
function stateLabel(server: CollectionServer) {
|
||||||
if (waitingForPort(server)) return 'Waiting for removal';
|
if (waitingForPort(server)) return 'Waiting for removal';
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
|
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title={collection?.name ?? 'Collection launch'}>
|
<SectionHeader title={collection?.name ?? 'Collection launch'}>
|
||||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: 'Collection launch' }]} />{/snippet}
|
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
|
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
|
||||||
{#if collection && detail}
|
{#if collection && detail}
|
||||||
@@ -2,5 +2,8 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
|
|
||||||
/** Preserve bookmarked operation links and list filters. */
|
/** Preserve bookmarked operation links and list filters. */
|
||||||
export function load({ url }: { url: URL }) {
|
export function load({ url }: { url: URL }) {
|
||||||
|
if (url.pathname.startsWith('/deployments/collections/')) {
|
||||||
|
redirect(308, url.pathname.replace('/deployments/collections/', '/collections/launches/') + url.search);
|
||||||
|
}
|
||||||
redirect(308, url.pathname.replace(/^\/deployments(?=\/|$)/, '/provisioning') + url.search);
|
redirect(308, url.pathname.replace(/^\/deployments(?=\/|$)/, '/provisioning') + url.search);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
|
||||||
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
|
import HousePartyDialog from '$ui/components/HousePartyDialog.svelte';
|
||||||
import type { CollectionSummary } from '$lib/deployment-plan.js';
|
|
||||||
|
|
||||||
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
|
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
|
||||||
import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
|
import { provisioningActions, actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
|
||||||
@@ -34,10 +33,8 @@
|
|||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let housePartyOpen = $state(false);
|
let housePartyOpen = $state(false);
|
||||||
let housePartyNotice = $state('');
|
let housePartyNotice = $state('');
|
||||||
let collections = $state<CollectionSummary[]>([]);
|
|
||||||
let tab = $state('active');
|
let tab = $state('active');
|
||||||
let limit = $state(25);
|
let limit = $state(25);
|
||||||
let collectionLimit = $state(10);
|
|
||||||
let historyState = $state('all');
|
let historyState = $state('all');
|
||||||
let cancelling = $state(false);
|
let cancelling = $state(false);
|
||||||
let cancellationNotice = $state('');
|
let cancellationNotice = $state('');
|
||||||
@@ -48,12 +45,11 @@
|
|||||||
historyState = params.get('status') === 'attention' ? 'attention' : 'all';
|
historyState = params.get('status') === 'attention' ? 'attention' : 'all';
|
||||||
search = params.get('server') ?? '';
|
search = params.get('server') ?? '';
|
||||||
limit = Math.max(25, Number(params.get('limit')) || 25);
|
limit = Math.max(25, Number(params.get('limit')) || 25);
|
||||||
collectionLimit = Math.max(10, Number(params.get('collections')) || 10);
|
|
||||||
});
|
});
|
||||||
function saveContext(reset = false) {
|
function saveContext(reset = false) {
|
||||||
if (reset) { limit = 25; collectionLimit = 10; }
|
if (reset) limit = 25;
|
||||||
const url = new URL($page.url);
|
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) : '' })) {
|
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) : '' })) {
|
||||||
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
if (value) url.searchParams.set(key, value); else url.searchParams.delete(key);
|
||||||
}
|
}
|
||||||
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||||
@@ -67,7 +63,6 @@
|
|||||||
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.` : ''}`;
|
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();
|
cancelling = false; await load();
|
||||||
}
|
}
|
||||||
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:'history',label:'History'}]);
|
const tabs = $derived.by(() => [{id:'active',label:'Active',badge:active.length},{id:'history',label:'History'}]);
|
||||||
let action = $state('all');
|
let action = $state('all');
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
@@ -82,10 +77,9 @@
|
|||||||
if (refreshing) return;
|
if (refreshing) return;
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
try {
|
try {
|
||||||
const [res, collectionRes] = await Promise.all([fetch('/api/provisioning'), fetch('/api/deployment-collections')]);
|
const res = await fetch('/api/provisioning');
|
||||||
if (!res.ok || !collectionRes.ok) throw new Error('Could not load provisioning activity.');
|
if (!res.ok) throw new Error('Could not load provisioning activity.');
|
||||||
deployments = (await res.json()).deployments;
|
deployments = (await res.json()).deployments;
|
||||||
collections = (await collectionRes.json()).collections;
|
|
||||||
error = '';
|
error = '';
|
||||||
} catch (err) { error = (err as Error).message; }
|
} catch (err) { error = (err as Error).message; }
|
||||||
finally { loading = false; refreshing = false; }
|
finally { loading = false; refreshing = false; }
|
||||||
@@ -108,20 +102,6 @@
|
|||||||
{#if cancellationNotice}<p role="status">{cancellationNotice}</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 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}
|
|
||||||
<section>
|
|
||||||
<h2>Collection launches</h2>
|
|
||||||
<div class="rows">{#each visibleCollections.slice(0,collectionLimit) as collection (collection.id)}
|
|
||||||
<a class="deployment-row collection-row" href="/provisioning/collections/{collection.id}">
|
|
||||||
<div class="row-head"><strong>{collection.name}</strong><Badge variant={collection.active ? 'info' : collection.failed ? 'danger' : 'online'}>{collection.active ? collection.provisioning + collection.verifying ? collection.mode === 'exact' ? 'Applying collection' : 'Launching' : 'Queued' : collection.failed ? 'Needs attention' : 'Completed'}</Badge></div>
|
|
||||||
<div class="collection-counts"><span><strong>{collection.done} / {collection.total}</strong> {collection.mode === 'exact' ? 'completed' : 'ready'}</span>{#if collection.provisioning + collection.verifying}<span>{collection.provisioning + collection.verifying} {collection.mode === 'exact' ? 'in progress' : 'launching'}</span>{/if}{#if collection.queued}<span>{collection.queued} queued</span>{/if}{#if collection.failed}<span class="error">{collection.failed} need attention</span>{/if}</div>
|
|
||||||
<div class="collection-meter" aria-hidden="true"><span class="verified" style:width={`${collection.done / (collection.total || 1) * 100}%`}></span><span class="unsuccessful" style:width={`${collection.failed / (collection.total || 1) * 100}%`}></span></div>
|
|
||||||
<div class="collection-footer"><span class="meta">Queued {fmt(collection.created_at)}</span><span class="collection-link">{collection.active ? 'Follow progress' : 'View results'} →</span></div>
|
|
||||||
</a>
|
|
||||||
{/each}</div>
|
|
||||||
<ListFooter total={visibleCollections.length} bind:limit={collectionLimit} onchange={() => saveContext()} step={10} label="collections" />
|
|
||||||
</section>
|
|
||||||
{/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'; saveContext(true); }}>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}
|
{#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}
|
||||||
@@ -176,7 +156,6 @@
|
|||||||
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
|
.deployment-row { display: block; text-decoration: none; color: inherit; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow-wrap: anywhere; }
|
||||||
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
|
.deployment-row:hover { border-color: var(--border-strong); background: var(--surface-raised); }
|
||||||
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
.deployment-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; }
|
||||||
.collection-row { display: grid; gap: 16px; }.collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: 13px; color: var(--text-secondary); }.collection-counts strong { color: var(--state-online); font-weight: 600; font-variant-numeric: tabular-nums; }.collection-meter { display: flex; height: 4px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); }.collection-meter .verified { background: var(--state-online); }.collection-meter .unsuccessful { background: var(--state-danger); }.collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; }.collection-footer .meta, .collection-link { font-size: 12px; }.collection-link { color: var(--accent-primary-hover); }
|
|
||||||
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
|
.server { margin-left: 12px; font-family: var(--font-mono); font-size: 14px; }
|
||||||
.meta { font-size: 13px; color: var(--text-secondary); }
|
.meta { font-size: 13px; color: var(--text-secondary); }
|
||||||
.error { color: var(--state-danger); }
|
.error { color: var(--state-danger); }
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
});
|
});
|
||||||
const createSteps = ['Prepare server files', 'Create Komodo deployment', 'Start container', 'Verify Komodo health', 'Verify RCON health', 'Verify selected map'];
|
const createSteps = ['Prepare server files', 'Create Komodo deployment', 'Start container', 'Verify Komodo health', 'Verify RCON health', 'Verify selected map'];
|
||||||
const createStages = ['Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
|
const createStages = ['Preparing server files', 'Creating Komodo deployment', 'Starting container', 'Waiting for Komodo health', 'Waiting for RCON health', 'Waiting for selected map'];
|
||||||
const destroySteps = ['Wait for empty server', 'Stop and remove container', 'Delete Komodo deployment', 'Remove server files and registry'];
|
const destroySteps = ['Wait for eligibility', 'Stop and remove container', 'Delete Komodo deployment', 'Remove server files and registry'];
|
||||||
const destroyStages = ['Waiting to verify', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry'];
|
const destroyStages = ['Waiting to verify', 'Stopping and removing container', 'Deleting Komodo deployment', 'Removing server files and registry'];
|
||||||
let steps = $derived(deployment?.action === 'destroy' ? ['Request destruction', ...destroySteps]
|
let steps = $derived(deployment?.action === 'destroy' ? ['Request destruction', ...destroySteps]
|
||||||
: deployment?.action === 'rebuild' ? ['Request rebuild', ...destroySteps, 'Queue fresh instance', ...createSteps]
|
: deployment?.action === 'rebuild' ? ['Request rebuild', ...destroySteps, 'Queue fresh instance', ...createSteps]
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
? 'The server is still registered. Inspect its status, then resume health verification without creating or restarting a container.'
|
? 'The server is still registered. Inspect its status, then resume health verification without creating or restarting a container.'
|
||||||
: 'Inspect the error and any remaining container or files before launching again. The activity below records how far this operation reached.';
|
: 'Inspect the error and any remaining container or files before launching again. The activity below records how far this operation reached.';
|
||||||
if (deployment.state === 'queued') return 'Your request is saved in the launch queue. A startup slot opens after an earlier server passes its container, RCON, and map checks.';
|
if (deployment.state === 'queued') return 'Your request is saved in the launch queue. A startup slot opens after an earlier server passes its container, RCON, and map checks.';
|
||||||
if (deployment.action === 'rebuild' && deployment.state === 'waiting') return 'Waiting until the instance is empty and a creation slot is available. The old instance and all of its files will be deleted before a fresh instance is created.';
|
if (deployment.action === 'rebuild' && deployment.state === 'waiting') return 'The rebuild waits for other work to finish, an empty server, and available recovery and startup capacity. The old instance and all of its files will be deleted before a fresh instance is created.';
|
||||||
if (deployment.state === 'waiting') return 'The server stays online while players finish. Removal begins only after a live check confirms it is empty.';
|
if (deployment.state === 'waiting') return 'The server stays online while players finish. Removal begins only after a live check confirms it is empty.';
|
||||||
if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.';
|
if (deployment.stage.startsWith('Waiting for selected map')) return 'Container and RCON checks passed. Waiting for RCON to report the selected engine map. Inspect the server configuration if this persists.';
|
||||||
if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.';
|
if (deployment.stage.startsWith('Waiting for RCON')) return 'The container is healthy. Waiting for the game to answer RCON; check its startup logs, password, and network if this persists.';
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: deployment ? serverTitle(deployment.server) : 'Details' }]} />{/snippet}
|
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { 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}
|
||||||
{#if detail?.collection}<a href="/provisioning/collections/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
|
{#if detail?.collection}<a href="/collections/launches/{detail.collection.id}">← Back to {detail.collection.name} collection</a>{/if}
|
||||||
{#if deployment}
|
{#if deployment}
|
||||||
<section class="summary">
|
<section class="summary">
|
||||||
<div class="top"><StatusBadge state={deployment.state} />
|
<div class="top"><StatusBadge state={deployment.state} />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { getCollection } from '$lib/server/fleet/collections.js';
|
|
||||||
|
|
||||||
export function load({ params }: { params: { id: string } }) {
|
/** Keep bookmarked collection launches under their current Collections home. */
|
||||||
if (!getCollection(params.id)) error(404, 'Collection not found.');
|
export function load({ params, url }: { params: { id: string }; url: URL }) {
|
||||||
return {};
|
redirect(308, `/collections/launches/${encodeURIComponent(params.id)}${url.search}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
<SectionHeader title="Keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
|
<SectionHeader title="Keys" subtitle="Save a Plutonium key for each server ID. Keys are kept when a server is destroyed.">
|
||||||
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
|
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
|
<p class="key-help">Saved keys are reused when a server is created or rebuilt. Electric Cherry does not check whether Plutonium accepts them. Updating or removing a saved key does not change an existing server.</p>
|
||||||
<div class="ui-toolbar"><input type="search" aria-label="Search keys" bind:value={search} oninput={event => { search = event.currentTarget.value; limit = 25; saveSearch(); }} placeholder="Search servers…" /></div>
|
<div class="ui-toolbar"><input type="search" aria-label="Search 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}
|
||||||
@@ -100,12 +101,12 @@
|
|||||||
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key here or save one when creating a server." />
|
{:else if !filtered.length}<EmptyState title={keys.length ? "No matching keys" : "No saved keys"} description="Add a key here or save one when creating a server." />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table-wrap"><table>
|
<div class="table-wrap"><table>
|
||||||
<thead><tr><th>Server ID</th><th>Status</th><th>Key</th><th>Actions</th></tr></thead>
|
<thead><tr><th>Server ID</th><th>Server</th><th>Last saved</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>{#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>{#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 ? 'Server exists' : 'No server deployed'}</td>
|
<td>{key.deployed ? 'Server exists' : 'No server deployed'}</td>
|
||||||
<td>Stored, not validated<small>Saved {savedAt(key.updated_at)}<br />Used on next deployment</small></td>
|
<td>{savedAt(key.updated_at)}</td>
|
||||||
<td><div class="actions">
|
<td><div class="actions">
|
||||||
<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 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 ariaLabel={`Remove key for ${serverTitle(key.server_id)}`} 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>
|
||||||
@@ -125,7 +126,7 @@
|
|||||||
<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>{: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}
|
</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}
|
{#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="Used when this server ID is created or rebuilt. Existing servers keep their current key.">
|
||||||
<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">
|
||||||
@@ -136,7 +137,8 @@
|
|||||||
{#if !validId || !plutoKey.trim()}<p role="status">Choose a valid server ID and enter its Plutonium key to save.</p>{/if}
|
{#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; }
|
a { color: var(--accent-primary-hover); }
|
||||||
|
.key-help { margin: 0; font-size: var(--text-sm); line-height: 1.65; }
|
||||||
.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; }
|
.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; } }
|
@media (max-width: 600px) { .id-picker { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getCollection } from '../../src/lib/server/fleet/collections.js';
|
||||||
|
import { load as collectionLoad } from '../../src/routes/collections/launches/[id]/+page.server.js';
|
||||||
|
import { load as provisioningCollectionLoad } from '../../src/routes/provisioning/collections/[id]/+page.server.js';
|
||||||
|
import { load as deploymentsLoad } from '../../src/routes/deployments/[...path]/+page.server.js';
|
||||||
|
|
||||||
|
vi.mock('../../src/lib/server/fleet/collections.js', () => ({ getCollection: vi.fn() }));
|
||||||
|
|
||||||
|
beforeEach(() => vi.resetAllMocks());
|
||||||
|
|
||||||
|
describe('collection launch routes', () => {
|
||||||
|
it('loads an existing launch at its Collections location and preserves missing-launch errors', () => {
|
||||||
|
vi.mocked(getCollection).mockReturnValue({ collection: { id: 'launch-123' } } as ReturnType<typeof getCollection>);
|
||||||
|
expect(collectionLoad({ params: { id: 'launch-123' } })).toEqual({});
|
||||||
|
expect(getCollection).toHaveBeenCalledWith('launch-123');
|
||||||
|
|
||||||
|
vi.mocked(getCollection).mockReturnValue(null);
|
||||||
|
expect(() => collectionLoad({ params: { id: 'missing' } })).toThrow(expect.objectContaining({ status: 404, body: { message: 'Collection not found.' } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects Provisioning collection bookmarks with the same ID and query parameters', () => {
|
||||||
|
expect(() => provisioningCollectionLoad({
|
||||||
|
params: { id: 'launch-123' },
|
||||||
|
url: new URL('http://localhost/provisioning/collections/launch-123?view=history&limit=50')
|
||||||
|
})).toThrow(expect.objectContaining({ status: 308, location: '/collections/launches/launch-123?view=history&limit=50' }));
|
||||||
|
expect(getCollection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['/deployments/collections/launch-123?limit=50&from=old%20link', '/collections/launches/launch-123?limit=50&from=old%20link'],
|
||||||
|
['/deployments/operation-123?from=collection', '/provisioning/operation-123?from=collection'],
|
||||||
|
['/deployments?action=create&limit=50', '/provisioning?action=create&limit=50']
|
||||||
|
])('keeps legacy %s bookmarks compatible', (path, location) => {
|
||||||
|
expect(() => deploymentsLoad({ url: new URL(path, 'http://localhost') })).toThrow(expect.objectContaining({ status: 308, location }));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user