<script server>
let __r = null
try { __r = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __r = null }
const capabilities = __r?.viewer?.capabilities ?? []
const canOperate = capabilities.includes('deployments:create')
</script>
<script client>
const data = state({ releases: [], resources: [], environments: [] })
const selected = state('')
const message = state('')
const filters = state({ environment: '', status: '', kind: '' })
const promote = state({ targetResourceId: '', config: '{}', strategy: '', approvalRequired: true })
async function request(body){const response=await fetch('/api/releases/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});const result=await response.json();if(!response.ok||result.ok===false)throw new Error(result.error||'Release action failed.');return result}
async function refresh(){try{const response=await fetch('/api/releases');const result=await response.json();if(!response.ok)throw new Error(result.error||'Releases could not be loaded.');data.set(result);if(!selected()&&result.releases[0])selected.set(result.releases[0].id)}catch(error){message.set(error.message)}}
function current(){return data().releases.find(value=>value.id===selected())||data().releases[0]}
function visible(){return data().releases.filter(value=>(!filters().environment||value.environmentId===filters().environment)&&(!filters().status||value.status===filters().status)&&(!filters().kind||value.kind===filters().kind))}
function tone(status){return status==='active'?'ok':['failed','rolled_back'].includes(status)?'bad':['activating','awaiting_approval'].includes(status)?'warn':''}
function short(value){return value?String(value).slice(0,12):'—'}
function duration(item){if(!item?.activatedAt&&!item?.failedAt)return'Pending';const end=new Date(item.activatedAt||item.failedAt);return Math.max(0,Math.round((end-new Date(item.createdAt))/1000))+'s'}
function configObject(){try{return JSON.parse(promote().config)}catch{throw new Error('Promotion config must be valid JSON.')}}
async function act(action,extra={}){const item=current();if(!item)return;try{await request({id:item.id,action,...extra});message.set(action+' accepted for '+item.resource.slug+'.');await refresh()}catch(error){message.set(error.message)}}
async function approve(decision){const item=current();const confirm=prompt('Type '+item.environment.slug+' to '+decision+' this environment gate');if(confirm==null)return;await act('approve',{decision,confirm,comment:prompt('Approval note (optional)')||undefined})}
async function rollback(){const item=current();const target=item.previous;if(!target)return;const confirm=prompt('Rollback preview:\nCurrent '+short(item.artifact.digest)+' / config '+short(item.configHash)+'\nRestore release '+short(target.id)+' / config '+short(target.configHash)+'\nData: '+item.comparison.dataCaveat+'\n\nType '+item.resource.slug+' to continue');if(confirm==null)return;await act('rollback',{targetReleaseId:target.id,confirm})}
async function promoteRelease(event){event.preventDefault();const item=current();const target=data().resources.find(value=>value.id===promote().targetResourceId);const environment=data().environments.find(value=>value.id===target?.environmentId);if(!target||!environment){message.set('Choose a promotion target.');return}const confirm=prompt('Promote the exact artifact '+item.artifact.digest+' without rebuilding.\nType '+environment.slug+' to continue');if(confirm==null)return;let config;try{config=configObject()}catch(error){message.set(error.message);return}await act('promote',{targetResourceId:target.id,config,strategy:promote().strategy||undefined,approvalRequired:promote().approvalRequired,confirm})}
onMount(()=>{refresh();const timer=setInterval(refresh,5000);return()=>clearInterval(timer)})
</script>
<!DOCTYPE html><html lang="en" data-theme="dark"><head>@include('../partials/head',{title:'ts-cloud - Releases & promotion'})</head><body>
@include('../partials/nav',{active:'releases',env:'production'})
<main class="releases-wrap wrap" id="main-content"><div class="crumbs">Operations <span class="sep">/</span> Releases & promotion</div><header class="page"><div><h1>Releases & promotion</h1><p>One immutable artifact history across static, compute, serverless, containers, and Compose.</p></div><button class="btn ghost" @click="refresh()">Refresh</button></header><p class="notice" @show="!!message()">{{message()}}</p>
<section class="section stats"><div class="panel"><small>Total releases</small><strong>{{data().releases.length}}</strong></div><div class="panel"><small>Active</small><strong>{{data().releases.filter(v=>v.status==='active').length}}</strong></div><div class="panel"><small>Needs attention</small><strong>{{data().releases.filter(v=>['failed','awaiting_approval'].includes(v.status)).length}}</strong></div><div class="panel"><small>Pinned</small><strong>{{data().releases.filter(v=>v.pinned).length}}</strong></div></section>
<section class="panel section toolbar"><label>Environment<select :value="filters().environment" @change="filters.set({...filters(),environment:$event.target.value})"><option value="">All</option><option @each="env in data().environments" :value="env.id">{{env.name}}</option></select></label><label>Status<select :value="filters().status" @change="filters.set({...filters(),status:$event.target.value})"><option value="">All</option><option @each="status in ['built','awaiting_approval','activating','active','failed','rolled_back','superseded']" :value="status">{{status}}</option></select></label><label>Kind<select :value="filters().kind" @change="filters.set({...filters(),kind:$event.target.value})"><option value="">All</option><option @each="kind in ['static','compute','serverless_zip','serverless_image','container','compose']" :value="kind">{{kind}}</option></select></label></section>
<div class="release-layout section"><section class="panel release-list"><div class="empty" @show="!visible().length"><b>No releases match this view</b><span>Create a release from a verified artifact with the CLI or API.</span></div><button class="release-row" :class="selected()===item.id?'selected':''" @each="item in visible()" :key="item.id" @click="selected.set(item.id)"><span class="pill" :class="tone(item.status)">{{item.status}}</span><strong>{{item.resource?.name||item.resourceId}}</strong><small>{{item.environment?.name}} · {{item.kind}} · {{item.strategy}}</small><code>{{short(item.sourceSha)}} · {{short(item.artifact?.digest)}}</code><time>{{new Date(item.createdAt).toLocaleString()}}</time></button></section>
<section class="detail" @show="!!current()"><article class="hero panel"><div><span class="pill" :class="tone(current()?.status)">{{current()?.status}}</span><h2>{{current()?.resource?.name}}</h2><p>{{current()?.environment?.name}} · {{current()?.kind}} · {{current()?.strategy}}</p></div><div class="actions">@if(canOperate)<button class="btn sm" @show="['built','failed','rolled_back','superseded'].includes(current()?.status)" @click="act('activate')">Activate</button><button class="btn sm" @show="current()?.status==='awaiting_approval'" @click="approve('approved')">Approve</button><button class="btn ghost sm" @show="current()?.status==='awaiting_approval'" @click="approve('rejected')">Reject</button><button class="btn danger sm" @show="current()?.status==='active'&&!!current()?.previous" @click="rollback()">Rollback…</button><button class="btn ghost sm" @click="act('pin',{pinned:!current()?.pinned,reason:current()?.pinned?undefined:'operator retention'})">{{current()?.pinned?'Unpin':'Pin'}}</button>@endif</div></article>
<article class="facts panel"><h3>Immutable identity</h3><dl><div><dt>Source commit</dt><dd class="mono">{{current()?.sourceSha||'Not recorded'}}</dd></div><div><dt>Artifact digest</dt><dd class="mono">{{current()?.artifact?.digest}}</dd></div><div><dt>Artifact URI</dt><dd class="mono">{{current()?.artifact?.uri}}</dd></div><div><dt>Config hash</dt><dd class="mono">{{current()?.configHash}}</dd></div><div><dt>Trigger / actor</dt><dd>{{current()?.trigger}} · {{short(current()?.actorId)}}</dd></div><div><dt>Duration</dt><dd>{{duration(current())}}</dd></div><div><dt>Provider versions</dt><dd class="mono">{{JSON.stringify(current()?.transitions.at(-1)?.health||{})}}</dd></div><div><dt>Provenance / attestation</dt><dd class="mono">{{JSON.stringify({provenance:current()?.artifact?.provenance,attestation:current()?.artifact?.attestation})}}</dd></div></dl></article>
<article class="caveat panel" @show="!!current()?.comparison"><h3>Change and rollback preview</h3><div class="change-grid"><span>Artifact <b>{{current()?.comparison.artifactChanged?'changed':'identical'}}</b></span><span>Source <b>{{current()?.comparison.sourceChanged?'changed':'identical'}}</b></span><span>Config <b>{{current()?.comparison.configChanged?'changed':'identical'}}</b></span><span>Manifest <b>{{current()?.comparison.manifestChanges.length}} change(s)</b></span></div><p>{{current()?.comparison.dataCaveat}}</p><p class="bad-text" @show="current()?.hooks.migrations==='irreversible'">Irreversible migration hooks are recorded. Traffic rollback will not restore data.</p></article>
<article class="panel"><h3>Activation steps & health</h3><div class="timeline"><div @each="step in current()?.transitions||[]"><span>{{step.trafficPercent==null?'—':step.trafficPercent+'%'}}</span><b>{{step.toStatus}}</b><p>{{step.message}}</p><time>{{new Date(step.createdAt).toLocaleString()}}</time></div></div></article>
<article class="panel"><h3>Strategy capability</h3><div class="strategy-grid"><div @each="cap in current()?.capabilities||[]" :class="cap.supported?'supported':'unsupported'"><b>{{cap.strategy}}</b><span>{{cap.supported?'Available':'Unavailable'}}</span><p>{{cap.explanation}}</p><small>{{cap.capacityMultiplier}}× capacity · {{cap.costImpact}} cost</small><small>{{cap.rollback}}</small></div></div></article>
@if(canOperate)<form class="panel promotion" @show="['active','superseded'].includes(current()?.status)" @submit="promoteRelease($event)"><h3>Promote without rebuild</h3><p>The exact digest above is reused. Only target config, strategy, and approval policy may change.</p><label>Target<select required :value="promote().targetResourceId" @change="promote.set({...promote(),targetResourceId:$event.target.value})"><option value="">Choose environment / resource…</option><option @each="resource in data().resources.filter(v=>v.id!==current()?.resourceId)" :value="resource.id">{{data().environments.find(e=>e.id===resource.environmentId)?.name}} / {{resource.name}}</option></select></label><label>Target config (non-secret JSON)<textarea rows="5" :value="promote().config" @input="promote.set({...promote(),config:$event.target.value})"></textarea></label><label>Strategy<select :value="promote().strategy" @change="promote.set({...promote(),strategy:$event.target.value})"><option value="">Keep {{current()?.strategy}}</option><option @each="cap in current()?.capabilities.filter(v=>v.supported)||[]" :value="cap.strategy">{{cap.strategy}}</option></select></label><label class="check"><input type="checkbox" :checked="promote().approvalRequired" @change="promote.set({...promote(),approvalRequired:$event.target.checked})"> Require target approval</label><button class="btn" type="submit">Preview and promote exact artifact</button></form>@endif
</section></div></main><style>.releases-wrap{max-width:1500px}.page,.hero,.actions{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.stats strong{display:block;font-size:1.7rem;margin-top:8px}.toolbar{display:flex;gap:12px}.toolbar label,.promotion label{display:grid;gap:5px}.release-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:14px;align-items:start}.release-list{padding:7px;max-height:1100px;overflow:auto}.release-row{width:100%;display:grid;grid-template-columns:auto 1fr;text-align:left;gap:4px 8px;padding:12px;background:transparent;color:inherit;border:0;border-bottom:1px solid var(--line);cursor:pointer}.release-row:hover,.release-row.selected{background:var(--bg3)}.release-row small,.release-row code,.release-row time{grid-column:2;color:var(--txt3);font-size:11px}.detail{display:grid;gap:12px}.hero h2{margin:8px 0 3px}.hero p{margin:0;color:var(--txt3)}.facts dl{display:grid;grid-template-columns:1fr 1fr;gap:10px}.facts dt{color:var(--txt3);font-size:10px}.facts dd{margin:3px 0;overflow-wrap:anywhere}.change-grid,.strategy-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.change-grid span,.strategy-grid>div{border:1px solid var(--line);padding:10px;border-radius:8px}.strategy-grid>div{display:grid;gap:6px}.strategy-grid .unsupported{opacity:.6}.strategy-grid p{margin:0;font-size:12px}.strategy-grid small{color:var(--txt3)}.timeline>div{display:grid;grid-template-columns:55px 110px 1fr auto;gap:10px;padding:10px 0;border-bottom:1px solid var(--line);align-items:center}.timeline p{margin:0}.timeline time{color:var(--txt3);font-size:11px}.promotion{display:grid;gap:10px}.check{display:flex!important;align-items:center}.empty span{display:block;color:var(--txt3);margin-top:5px}@media(max-width:1050px){.release-layout{grid-template-columns:1fr}.release-list{max-height:360px}.strategy-grid{grid-template-columns:1fr 1fr}}@media(max-width:650px){.stats,.facts dl,.change-grid,.strategy-grid{grid-template-columns:1fr 1fr}.toolbar,.page,.hero{display:grid}.timeline>div{grid-template-columns:45px 1fr}.timeline p,.timeline time{grid-column:2}}@media(max-width:420px){.stats,.facts dl,.change-grid,.strategy-grid{grid-template-columns:1fr}}</style></body></html>
