import { statusPresentation, type StatusTone } from './status.js'; export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive'; export interface CampaignSummary { action: string; beta_id: number | null; rolled_back_by: string | null; script_name: string | null; script_version: number | null; scripts: { script_name: string | null; script_version: number | null }[]; servers: { restart_state: string; server_deleted_at?: string | null }[]; } export function campaignTitle(campaign: CampaignSummary): string { const action = campaign.action === 'drift' ? 'Maintenance repair' : campaign.action.charAt(0).toUpperCase() + campaign.action.slice(1); if (campaign.action === 'drift') return action; if (campaign.action === 'rollback' && campaign.beta_id) return `${action} · Beta #${campaign.beta_id}`; if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`; const script = campaign.scripts[0] ?? campaign; if (script.script_name) return `${action} · ${script.script_name}${script.script_version !== null ? ` v${script.script_version}` : ''}`; return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action; } export function campaignOutcome(campaign: Pick): { status: 'attention' | 'progress' | 'rolled-back' | 'complete' | 'inactive'; label: string; tone: StatusTone; completed: number; } { const completed = campaign.servers.filter(server => server.restart_state === 'done').length; const live = campaign.servers.filter(server => !server.server_deleted_at); const attention = live.filter(server => ['failed', 'held', 'manual'].includes(server.restart_state)); if (attention.length) return { status: 'attention', label: `${attention.length} need attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed }; if (live.some(server => server.restart_state === 'pending')) return { status: 'progress', label: `${completed}/${campaign.servers.length} complete`, tone: 'info', completed }; if (campaign.rolled_back_by) return { status: 'rolled-back', label: 'Rolled back', tone: 'neutral', completed }; if (campaign.servers.length && completed === campaign.servers.length) return { status: 'complete', label: 'Complete', tone: 'online', completed }; return { status: 'inactive', label: completed ? `${completed}/${campaign.servers.length} complete` : 'Inactive', tone: 'neutral', completed }; } /** Progress comes from recorded state, not the error field, which also contains routine stage messages. */ export function campaignServerStatus(server: { restart_state: string; server_deleted_at?: string | null; restarted_at: string | null; }, files: { apply_state: string }[] = []): { group: CampaignServerGroup; label: string; tone: StatusTone } { if (server.restart_state === 'done') return { group: 'complete', label: 'Complete', tone: 'online' }; if (server.server_deleted_at) return { group: 'inactive', label: 'Deleted before activation', tone: 'neutral' }; if (['skipped', 'cancelled'].includes(server.restart_state)) return { group: 'inactive', ...statusPresentation(server.restart_state) }; if (['failed', 'manual', 'held'].includes(server.restart_state)) { return { group: 'attention', ...statusPresentation(server.restart_state), tone: server.restart_state === 'failed' ? 'danger' : 'warning' }; } if (server.restarted_at) return { group: 'processing', label: 'Verifying health', tone: 'info' }; if (files.some(file => file.apply_state === 'applying')) return { group: 'processing', label: 'Applying files', tone: 'info' }; return { group: 'queued', label: 'Queued', tone: 'neutral' }; } /** Load only the requested history, respecting the API's per-request page bound. */ export async function fetchCampaigns(filters: Record, count: number, signal?: AbortSignal): Promise<{ deployments: T[]; total: number }> { const deployments: T[] = []; let total = Infinity; while (deployments.length < Math.min(count, total)) { const params = new URLSearchParams({ ...filters, limit: String(Math.min(500, count - deployments.length)), offset: String(deployments.length) }); const response = await fetch(`/api/campaigns?${params}`, { signal }); if (!response.ok) throw new Error('Could not refresh campaigns.'); const page = await response.json() as { deployments: T[]; total: number }; total = page.total; deployments.push(...page.deployments); if (!page.deployments.length) break; } return { deployments, total: Number.isFinite(total) ? total : 0 }; }