Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/lib/deployment/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
type DeploymentStatusFields = {
action: Api.DeploymentAction
status: Api.DeploymentStatus
}

export type DeploymentStatusTone = 'positive' | 'warning' | 'negative'

export type DeploymentStatusView = {
label: string
tone: DeploymentStatusTone
}

/**
* Canonical status copy + tone for a deployment. Shared by the list page and
* the detail header so the two cannot drift.
*
* Action is checked before status: a pending delete also has `status: 'pending'`,
* which would otherwise label as Pending.
*/
export function deploymentStatus (d: DeploymentStatusFields): DeploymentStatusView {
let label: string
if (d.action === 'pause') label = 'Paused'
else if (d.action === 'delete') label = 'Deleting'
else if (d.status === 'pending') label = 'Pending'
else if (d.status === 'error') label = 'Error'
else if (d.status === 'cancelled') label = 'Cancelled'
else label = 'Success'

let tone: DeploymentStatusTone
if (d.status === 'success' && d.action === 'pause') tone = 'warning'
else if (d.status === 'success') tone = 'positive'
else if (d.status === 'pending') tone = 'warning'
else tone = 'negative'

return { label, tone }
}

/** Pill next to the name. Hidden for a healthy running deployment. */
export function deploymentStatusPill (d: DeploymentStatusFields): DeploymentStatusView | null {
if (d.action === 'deploy' && d.status === 'success') return null
return deploymentStatus(d)
}
28 changes: 2 additions & 26 deletions src/routes/(auth)/(project)/deployment/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { onMount } from 'svelte'
import { getPermissionContext } from '$lib/permission'
import { registerPageActions } from '$lib/pageactions/store.svelte'
import { deploymentStatusPill } from '$lib/deployment/status'
import type { PageData } from './$types'

const { data }: { data: PageData } = $props()
Expand Down Expand Up @@ -55,31 +56,6 @@
Worker: { icon: 'fa-gears', label: 'Worker' },
CronJob: { icon: 'fa-clock', label: 'Cron Job' }
}

/**
* Static status label for the pill next to the name. A healthy running
* deployment returns null so its row stays clean — only noteworthy states
* (deleting / paused / deploying / failed / cancelled) get a labelled pill,
* mirroring the live icon to its left.
*
* A pending delete reuses the same `status: 'pending'` as a fresh deploy, so
* the action has to be checked first — otherwise a deployment being torn down
* would mislabel as "Deploying".
*/
function statusPill (it: Api.DeploymentListItem): { label: string, tone: string } | null {
if (it.action === 'delete') {
return { label: 'Deleting', tone: 'negative' }
}
if (it.action === 'pause') {
return { label: 'Paused', tone: 'warning' }
}
switch (it.status) {
case 'pending': return { label: 'Deploying', tone: 'info' }
case 'error': return { label: 'Failed', tone: 'negative' }
case 'cancelled': return { label: 'Cancelled', tone: 'muted' }
default: return null
}
}
</script>

<style>
Expand Down Expand Up @@ -139,7 +115,7 @@
<tbody>
{#each deployments as it (`${it.name}-${it.location}`)}
{@const type = typeMeta[it.type] ?? { icon: 'fa-cube', label: it.type }}
{@const pill = statusPill(it)}
{@const pill = deploymentStatusPill(it)}
{@const autoscale = it.minReplicas > 0 && it.minReplicas !== it.maxReplicas}
<tr>
<td>
Expand Down
40 changes: 8 additions & 32 deletions src/routes/(auth)/(project)/deployment/_components/Header.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import DeploymentStatusIcon from '$lib/components/DeploymentStatusIcon.svelte'
import DeploymentPodErrors from '$lib/deployment/DeploymentPodErrors.svelte'
import { deploymentStatusPill } from '$lib/deployment/status'
import { page } from '$app/stores'
import api from '$lib/api'
import * as modal from '$lib/modal'
Expand Down Expand Up @@ -33,32 +34,7 @@
// apiserver rejects static, cronjob, paused, and mid-deploy.
const canRestart = $derived(canPause && deployment.type !== 'CronJob')

const statusTone = $derived(
deployment.status === 'success' && deployment.action === 'pause'
? 'warn'
: deployment.status === 'success'
? 'positive'
: deployment.status === 'pending'
? 'warn'
: 'negative'
)

const statusLabel = $derived.by(() => {
if (deployment.action === 'pause') return 'Paused'
if (deployment.action === 'delete') return 'Deleting'
if (deployment.status === 'pending') return 'Pending'
if (deployment.status === 'error') return 'Error'
if (deployment.status === 'cancelled') return 'Cancelled'
return 'Success'
})

// The DeploymentStatusIcon next to the name already carries the visual
// signal for a healthy running deployment. Only surface the status pill
// in the meta row when it tells you something the icon can't (paused,
// deleting, pending, error). Keeps the common running case clean.
const showStatusPill = $derived(
deployment.action !== 'deploy' || deployment.status !== 'success'
)
const statusPill = $derived(deploymentStatusPill(deployment))

function pause () {
modal.confirm({
Expand Down Expand Up @@ -302,7 +278,7 @@
font-weight: 600;
}

.status-pill[data-tone='warn'] {
.status-pill[data-tone='warning'] {
background: hsl(var(--hsl-warning) / 0.12);
color: hsl(var(--hsl-warning));
}
Expand All @@ -323,7 +299,7 @@
background: hsl(var(--hsl-positive));
animation: live-pulse 1.8s ease-out infinite;
}
.meta__dot[data-tone='warn'] { background: hsl(var(--hsl-warning)); }
.meta__dot[data-tone='warning'] { background: hsl(var(--hsl-warning)); }
.meta__dot[data-tone='negative'] { background: hsl(var(--hsl-negative)); }

@keyframes live-pulse {
Expand Down Expand Up @@ -401,10 +377,10 @@
</div>

<div class="masthead__meta">
{#if showStatusPill}
<span class="status-pill" data-tone={statusTone}>
<span class="meta__dot" data-tone={statusTone}></span>
{statusLabel}
{#if statusPill}
<span class="status-pill" data-tone={statusPill.tone}>
<span class="meta__dot" data-tone={statusPill.tone}></span>
{statusPill.label}
</span>
<span class="meta__divider"></span>
{/if}
Expand Down
61 changes: 61 additions & 0 deletions tests/deployment.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,67 @@ test.describe('deployments', () => {
await expect(main.getByText(/Something went wrong while loading this data/)).toBeVisible()
await expect(main.getByRole('button', { name: 'Try again' })).toBeVisible()
})

test('list status pills match the detail page', async ({ page }) => {
const cases = [
{ name: 'web', status: 'success', action: 'deploy', pill: null },
{ name: 'web-pending', status: 'pending', action: 'deploy', pill: 'Pending' },
{ name: 'web-error', status: 'error', action: 'deploy', pill: 'Error' },
{ name: 'web-paused', status: 'success', action: 'pause', pill: 'Paused' },
{ name: 'web-deleting', status: 'pending', action: 'delete', pill: 'Deleting' },
{ name: 'web-cancelled', status: 'cancelled', action: 'deploy', pill: 'Cancelled' }
]

await setMocks({
'deployment.list': {
ok: true,
result: {
items: cases.map((c) => ({
...sampleDeployment,
name: c.name,
status: c.status,
action: c.action
}))
}
}
})

await page.goto('/deployment?project=test-project')
const main = page.locator('.content-wrapper')

for (const c of cases) {
const row = main.locator('tr', { has: page.getByRole('link', { name: c.name, exact: true }) })
if (c.pill) {
await expect(row.locator('.status-pill')).toHaveText(c.pill)
} else {
await expect(row.locator('.status-pill')).toHaveCount(0)
}
}
await expect(main.getByText('Deploying')).toHaveCount(0)
await expect(main.getByText('Failed')).toHaveCount(0)

for (const c of cases) {
await setMocks({
'deployment.get': {
ok: true,
result: {
...sampleDeployment,
name: c.name,
status: c.status,
action: c.action
}
},
'location.get': { ok: true, result: defaultLocation }
})
await page.goto(`/deployment/detail?project=test-project&location=gke&name=${c.name}`)
const pill = page.locator('.masthead .status-pill')
if (c.pill) {
await expect(pill).toHaveText(c.pill)
} else {
await expect(pill).toHaveCount(0)
}
}
})
})

test.describe('deployment detail — sidecars', () => {
Expand Down
Loading