Campaigns | Electric Cherry
{#if loadError}{/if} {#if loading}
Loading…
{:else} {#if driftError}{/if}
{#if driftOpen}
{#if drift}

Scanned {$timeFormat.dateTime(drift.scannedAt)}. Selection controls the action scope; search only changes the visible list.

{/if} {#if drift && drift.totalDrift > 0} { driftFilter = event.currentTarget.value; saveContext(); }} />
{selected.length} selected
{#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}
{#each s.files as f (f.driftKind + ':' + f.kind + ':' + f.relPath)} [{f.driftKind === 'missing' ? '+' : f.driftKind === 'replace' ? '↻' : '−'} {f.kind}] {f.relPath} {/each}
{/each} {:else if drift?.totalDrift === 0} {:else} {/if}
{/if} {#if result}
0} role={skipped.length ? 'alert' : 'status'}> {result.totalServers} server{result.totalServers === 1 ? '' : 's'} updated{skipped.length ? `, ${skipped.length} with skipped files` : ''}

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

{#if result.deploymentId}View campaign →{/if} {#each skipped as server}
    {#each server.skipped as reason}
  • {reason}
  • {/each}
{/each} {#if skipped.length}{/if}
{/if}

Active

{active.length}
{#if active.length === 0} {:else}
{#each active as d (d.id)} {@render deployRow(d)} {/each}
{/if}

History

{history.length}
{#if history.length === 0} {:else} {#each historyByDay as [day, rows] (day)}
{day}
{#each rows as d (d.id)} {@render deployRow(d)} {/each}
{/each} {/if}
{/if}
{#if planError}{/if} {#if reviewing}

Comparing current files with their intended versions…

{:else if plan}

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

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

Reviewed {$timeFormat.dateTime(plan.scannedAt)}. Changes to this plan require another review.

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

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

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

{/each}
{/if}{/each}
{/if} {#snippet actions()}{/snippet}
{#snippet deployRow(d: Deployment)}
{#if flash && flashId === d.id}
{flash}
{/if}
{d.action} campaign {d.id} {#if d.action === 'promote' || d.action === 'rollback'} {scriptSummary(d)} {/if} {#if d.rolled_back_by} Rolled back {/if}
{#if d.action === 'promote' && !d.rolled_back_by} {/if} {ago(d.started_at)}
{#each d.servers as s (s.server)} {serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''} {/each}
{d.id}
{#each ['done','pending','failed','held','manual'] as state} {@const n = d.servers.filter((s) => s.restart_state === state).length} {#if n > 0} {n} {/if} {/each}
{/snippet}