Simplify launch files history card (Refs #87)

This commit is contained in:
2026-10-09 14:31:59 -04:00
parent da623e8409
commit 15ab41d4a2
3 changed files with 13 additions and 20 deletions
+6 -6
View File
@@ -211,12 +211,12 @@ Uncertain inspections keep it waiting; the old command is never replayed merely
because its receipt was lost. because its receipt was lost.
Create and rebuild capture a setup when queued and select the latest promoted Create and rebuild capture a setup when queued and select the latest promoted
files again when creation begins. The page distinguishes **Setup captured when files again when creation begins. The **Launch files** card shows one selection
queued** from **Files selected for this attempt**. A queued capture does not pin status beside the capture time: **Captured when queued**, **Selected when creation
versions; an attempted selection is not proof that every file was installed. began**, or **Recorded setup; selection time unknown** for older records. A queued
**Launch another server** opens the creation dialog with the recorded game and capture does not pin versions; an attempted selection is not proof that every
map as editable starting values. It checks the latest promoted setup and creates file was installed. Expand the managed file list to inspect versions, paths,
a separate server with a fresh identity and port. checksums, and the recipe fingerprint.
### History and refresh behavior ### History and refresh behavior
+3 -3
View File
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js'; import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false, selectionStatus = null }: { recipe: LaunchRecipeSummary; historical?: boolean; selectionStatus?: 'queued' | 'attempted' | null } = $props(); let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } = $props();
</script> </script>
<div class="recipe"> <div class="recipe">
<div class="recipe-heading"><div><span class="eyebrow">{historical ? selectionStatus === 'queued' ? 'Queued setup capture' : selectionStatus === 'attempted' ? 'Selected for this attempt' : 'Recorded launch files' : 'Current promoted setup'}</span><h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div> <div class="recipe-heading"><div>{#if !historical}<span class="eyebrow">Current promoted setup</span>{/if}<h3>{recipe.mapName}</h3></div><span class="game">{recipe.game === 't6' ? 'Black Ops II' : 'Black Ops I'}</span></div>
<dl> <dl>
<div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div> <div><dt>Configuration</dt><dd>{recipe.configSource}</dd></div>
<div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div> <div><dt>Container image</dt><dd class="mono">{recipe.image}</dd></div>
@@ -14,7 +14,7 @@
<details> <details>
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary> <summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul> <ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
<p>{historical ? 'Recorded' : 'Previewed'} <RelativeTime value={recipe.capturedAt} />. {historical ? selectionStatus === 'queued' ? 'Versions are selected again when creation begins. This queued capture does not pin them.' : selectionStatus === 'attempted' ? 'These files were selected for this attempt; this does not confirm installation succeeded. Launch again uses the latest promoted setup.' : 'This records the saved setup for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p> {#if !historical}<p>Previewed <RelativeTime value={recipe.capturedAt} />. This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.</p>{/if}
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small> <small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
</details> </details>
</div> </div>
+4 -11
View File
@@ -20,7 +20,6 @@
import ProvisioningProgress from '$ui/components/ProvisioningProgress.svelte'; import ProvisioningProgress from '$ui/components/ProvisioningProgress.svelte';
import ProvisioningActivity from '$ui/components/ProvisioningActivity.svelte'; import ProvisioningActivity from '$ui/components/ProvisioningActivity.svelte';
import LaunchRecipe from '$ui/components/LaunchRecipe.svelte'; import LaunchRecipe from '$ui/components/LaunchRecipe.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import Freshness from '$ui/components/Freshness.svelte'; import Freshness from '$ui/components/Freshness.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { timestampISO, type Timestamp } from '$lib/time-zone.js'; import { timestampISO, type Timestamp } from '$lib/time-zone.js';
@@ -33,7 +32,6 @@
loadError: '', actionError: '', notice: '', refreshedAt: initial.generatedAt loadError: '', actionError: '', notice: '', refreshedAt: initial.generatedAt
}); });
let now = $state(Date.parse(initial.generatedAt)); let now = $state(Date.parse(initial.generatedAt));
let launchOpen = $state(false);
let copyStatus = $state(''); let copyStatus = $state('');
const controller = createProvisioningRefresh<ProvisioningDetail>({ validate: isProvisioningDetail, onChange: value => { view = value; } }); const controller = createProvisioningRefresh<ProvisioningDetail>({ validate: isProvisioningDetail, onChange: value => { view = value; } });
const detail = $derived(view.detail); const detail = $derived(view.detail);
@@ -47,11 +45,10 @@
const duration = $derived(finishedAt || active ? elapsedTime(startedAt, finishedAt ? Date.parse(finishedAt) : now) : 'Not recorded'); const duration = $derived(finishedAt || active ? elapsedTime(startedAt, finishedAt ? Date.parse(finishedAt) : now) : 'Not recorded');
const serverHref = $derived(deployment && deployment.server_exists && !deployment.server_deleted_at ? `/servers/${encodeURIComponent(deployment.server)}` : null); const serverHref = $derived(deployment && deployment.server_exists && !deployment.server_deleted_at ? `/servers/${encodeURIComponent(deployment.server)}` : null);
const recipeStatus = $derived(detail?.progress?.recipeStatus ?? null); const recipeStatus = $derived(detail?.progress?.recipeStatus ?? null);
const recipeHeading = $derived(recipeStatus === 'queued' ? 'Setup captured when queued' : recipeStatus === 'attempted' ? 'Files selected for this attempt' : 'Recorded launch files'); const recipeSelection = $derived(recipeStatus === 'queued' ? 'Captured when queued' : recipeStatus === 'attempted' ? 'Selected when creation began' : 'Recorded setup; selection time unknown');
$effect(() => { $effect(() => {
const snapshot = data.detail; const snapshot = data.detail;
untrack(() => { untrack(() => {
launchOpen = false;
copyStatus = ''; copyStatus = '';
now = Date.parse(snapshot.generatedAt); now = Date.parse(snapshot.generatedAt);
controller.setOperation(snapshot.deployment.id, snapshot, snapshot.generatedAt); controller.setOperation(snapshot.deployment.id, snapshot, snapshot.generatedAt);
@@ -186,13 +183,11 @@
{#if pool.holders.length}<details><summary>Current slot holders</summary><ul>{#each pool.holders as holder}<li>{#if holder.id}<a href={`/provisioning/${encodeURIComponent(holder.id)}`}>{serverTitle(holder.server)}</a>{:else}<span>{serverTitle(holder.server)} · operation not recorded</span>{/if}</li>{/each}</ul></details>{/if} {#if pool.holders.length}<details><summary>Current slot holders</summary><ul>{#each pool.holders as holder}<li>{#if holder.id}<a href={`/provisioning/${encodeURIComponent(holder.id)}`}>{serverTitle(holder.server)}</a>{:else}<span>{serverTitle(holder.server)} · operation not recorded</span>{/if}</li>{/each}</ul></details>{/if}
</div>{/each}<p class="small">Capacity snapshot <RelativeTime value={detail.capacity.evaluatedAt} missing="Not recorded" /></p> </div>{/each}<p class="small">Capacity snapshot <RelativeTime value={detail.capacity.evaluatedAt} missing="Not recorded" /></p>
</div></Card>{/if} </div></Card>{/if}
<Card as="section" padding="md" ariaLabel="Recorded launch inputs"><div class="context-section recipe"><h2>{recipeHeading}</h2> <Card as="section" padding="md" ariaLabel="Launch files"><div class="context-section recipe"><h2>Launch files</h2>
{#if detail.recipe} {#if detail.recipe}
{#if detail.blueprintName}<p>From {detail.blueprintName}</p>{/if} {#if detail.blueprintName}<p>From {detail.blueprintName}</p>{/if}
<p>{recipeStatus === 'queued' ? 'This capture does not pin versions. The latest promoted setup is selected again when creation begins.' : recipeStatus === 'attempted' ? 'These files were selected when creation began. Selection alone does not confirm successful installation.' : 'This is the saved recipe for this operation. The point at which it was selected was not recorded.'}</p> <p class="small">{recipeSelection} · <RelativeTime value={detail.recipe.capturedAt} missing="Capture time not recorded" /></p>
<p class="small">Captured {@render dateTime(detail.recipe.capturedAt)}</p> <LaunchRecipe recipe={detail.recipe} historical />
<LaunchRecipe recipe={detail.recipe} historical selectionStatus={recipeStatus} />
<div class="launch-another"><Button variant="ghost" onclick={() => launchOpen = true}>Launch another server →</Button><p>Creates a separate server with a fresh ID and port using the latest promoted files. It does not repair this instance.</p></div>
{:else}<p>{['create', 'rebuild'].includes(deployment.action) ? 'No launch recipe was recorded for this operation.' : 'This operation uses the existing instance. No new launch files were selected.'}</p>{/if} {:else}<p>{['create', 'rebuild'].includes(deployment.action) ? 'No launch recipe was recorded for this operation.' : 'This operation uses the existing instance. No new launch files were selected.'}</p>{/if}
</div></Card> </div></Card>
<details class="technical-details"> <details class="technical-details">
@@ -212,7 +207,6 @@
</details> </details>
</aside> </aside>
</div> </div>
{#if detail.recipe}<DeployServerDialog bind:open={launchOpen} sourceDeploymentId={deployment.id} />{/if}
{/key} {/key}
{:else}<div class="loading" role="status">Loading provisioning operation…</div>{/if} {:else}<div class="loading" role="status">Loading provisioning operation…</div>{/if}
</div> </div>
@@ -244,7 +238,6 @@
.copy-link { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); } .copy-link { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); }
.related, .capacity-pool ul { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--space-4); }.related li { display: grid; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }.related span { color: var(--text-tertiary); font-size: var(--text-xs); }.related strong { font-weight: var(--weight-medium); } .related, .capacity-pool ul { list-style: none; padding: 0; margin: 0; display: grid; gap: var(--space-4); }.related li { display: grid; gap: var(--space-1); font-size: var(--text-sm); overflow-wrap: anywhere; }.related span { color: var(--text-tertiary); font-size: var(--text-xs); }.related strong { font-weight: var(--weight-medium); }
.capacity-pool { display: grid; gap: var(--space-3); font-size: var(--text-xs); }.capacity-pool + .capacity-pool { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.capacity-heading { display: flex; justify-content: space-between; gap: var(--space-2); flex-wrap: wrap; }.capacity-heading strong { font-weight: var(--weight-medium); }.capacity-heading span { color: var(--text-secondary); }.capacity-pool summary { cursor: pointer; color: var(--text-secondary); }.capacity-pool ul { gap: var(--space-2); margin-top: var(--space-3); line-height: 1.6; } .capacity-pool { display: grid; gap: var(--space-3); font-size: var(--text-xs); }.capacity-pool + .capacity-pool { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.capacity-heading { display: flex; justify-content: space-between; gap: var(--space-2); flex-wrap: wrap; }.capacity-heading strong { font-weight: var(--weight-medium); }.capacity-heading span { color: var(--text-secondary); }.capacity-pool summary { cursor: pointer; color: var(--text-secondary); }.capacity-pool ul { gap: var(--space-2); margin-top: var(--space-3); line-height: 1.6; }
.launch-another { display: grid; gap: var(--space-2); justify-items: start; border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }.launch-another p { font-size: var(--text-xs); }
.loading { min-height: 240px; padding: var(--space-6); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); color: var(--text-secondary); } .loading { min-height: 240px; padding: var(--space-6); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); color: var(--text-secondary); }
@media (max-width: 1000px) { .detail-grid { grid-template-columns: minmax(0, 1fr); }.context-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 1000px) { .detail-grid { grid-template-columns: minmax(0, 1fr); }.context-column { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .page { gap: var(--space-5); }.outcome { padding: var(--space-4); gap: var(--space-4); }.outcome h2 { font-size: var(--text-lg); }.outcome-message p { font-size: var(--text-sm); }.context-column { grid-template-columns: minmax(0, 1fr); }.technical-details > summary { padding: var(--space-4); }.technical-content { padding: 0 var(--space-4) var(--space-4); }.timing { gap: var(--space-4); }.timing > div:last-child { flex-basis: 100%; }.detail-grid { gap: var(--space-6); } } @media (max-width: 600px) { .page { gap: var(--space-5); }.outcome { padding: var(--space-4); gap: var(--space-4); }.outcome h2 { font-size: var(--text-lg); }.outcome-message p { font-size: var(--text-sm); }.context-column { grid-template-columns: minmax(0, 1fr); }.technical-details > summary { padding: var(--space-4); }.technical-content { padding: 0 var(--space-4) var(--space-4); }.timing { gap: var(--space-4); }.timing > div:last-child { flex-basis: 100%; }.detail-grid { gap: var(--space-6); } }