ui(deploy): pending-deploy badge + Start/Stop in page header

1. Add a 'Pending deploy' Badge next to the app name when there are
   local edits or the saved state differs from the last deploy. Makes
   the undeployed-changes state visible even when the user isn't looking
   at the tab asterisks.

2. Move Start/Stop buttons from StatusCard into the page header, next
   to Delete. Runs off the latest deployment's status — Stop when
   RUNNING/STARTING/DEGRADED, Start (triggers a redeploy of the last
   version) when STOPPED. DeploymentTab and StatusCard shed their
   onStop/onStart props.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-23 00:51:11 +02:00
parent 9c54313ff1
commit 9ecc9ee72a
3 changed files with 47 additions and 43 deletions

View File

@@ -12,11 +12,9 @@ interface Props {
appSlug: string; appSlug: string;
envSlug: string; envSlug: string;
externalUrl: string; externalUrl: string;
onStop: (deploymentId: string) => void;
onStart: (deploymentId: string) => void;
} }
export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl, onStop, onStart }: Props) { export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl }: Props) {
const latest = deployments const latest = deployments
.slice() .slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null; .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
@@ -33,8 +31,6 @@ export function DeploymentTab({ deployments, versions, appSlug, envSlug, externa
deployment={latest} deployment={latest}
version={version} version={version}
externalUrl={externalUrl} externalUrl={externalUrl}
onStop={() => onStop(latest.id)}
onStart={() => onStart(latest.id)}
/> />
{latest.status === 'STARTING' && ( {latest.status === 'STARTING' && (
<DeploymentProgress currentStage={latest.deployStage} failed={false} /> <DeploymentProgress currentStage={latest.deployStage} failed={false} />

View File

@@ -1,4 +1,4 @@
import { Badge, StatusDot, MonoText, Button } from '@cameleer/design-system'; import { Badge, StatusDot, MonoText } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps'; import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { timeAgo } from '../../../../utils/format-utils'; import { timeAgo } from '../../../../utils/format-utils';
import styles from '../AppDeploymentPage.module.css'; import styles from '../AppDeploymentPage.module.css';
@@ -13,21 +13,13 @@ const DEPLOY_STATUS_DOT = {
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error', STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
} as const; } as const;
function formatBytes(bytes: number): string {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
interface Props { interface Props {
deployment: Deployment; deployment: Deployment;
version: AppVersion | null; version: AppVersion | null;
externalUrl: string; externalUrl: string;
onStop: () => void;
onStart: () => void;
} }
export function StatusCard({ deployment, version, externalUrl, onStop, onStart }: Props) { export function StatusCard({ deployment, version, externalUrl }: Props) {
const running = deployment.replicaStates?.filter((r) => r.status === 'RUNNING').length ?? 0; const running = deployment.replicaStates?.filter((r) => r.status === 'RUNNING').length ?? 0;
const total = deployment.replicaStates?.length ?? 0; const total = deployment.replicaStates?.length ?? 0;
@@ -56,12 +48,6 @@ export function StatusCard({ deployment, version, externalUrl, onStop, onStart }
{deployment.errorMessage} {deployment.errorMessage}
</div> </div>
)} )}
<div className={styles.statusCardActions}>
{(deployment.status === 'RUNNING' || deployment.status === 'STARTING' || deployment.status === 'DEGRADED')
&& <Button size="sm" variant="danger" onClick={onStop}>Stop</Button>}
{deployment.status === 'STOPPED' && <Button size="sm" variant="secondary" onClick={onStart}>Start</Button>}
</div>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router'; import { useParams, useLocation, useNavigate } from 'react-router';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { AlertDialog, Button, Tabs, useToast } from '@cameleer/design-system'; import { AlertDialog, Badge, Button, Tabs, useToast } from '@cameleer/design-system';
import { useEnvironmentStore } from '../../../api/environment-store'; import { useEnvironmentStore } from '../../../api/environment-store';
import { useEnvironments } from '../../../api/queries/admin/environments'; import { useEnvironments } from '../../../api/queries/admin/environments';
import { import {
@@ -60,6 +60,9 @@ export default function AppDeploymentPage() {
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug); const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null; const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null;
const activeDeployment = deployments.find((d) => d.status === 'STARTING') ?? null; const activeDeployment = deployments.find((d) => d.status === 'STARTING') ?? null;
const latestDeployment = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
const { data: agentConfig = null } = useApplicationConfig(app?.slug, selectedEnv); const { data: agentConfig = null } = useApplicationConfig(app?.slug, selectedEnv);
const { data: dirtyState, isLoading: dirtyLoading } = useDirtyState(selectedEnv, app?.slug); const { data: dirtyState, isLoading: dirtyLoading } = useDirtyState(selectedEnv, app?.slug);
@@ -398,7 +401,12 @@ export default function AppDeploymentPage() {
<div className={styles.container}> <div className={styles.container}>
{/* ── Page header ── */} {/* ── Page header ── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2> <h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2>
{app && !deploymentInProgress && (dirty.anyLocalEdit || serverDirtyAgainstDeploy) && (
<Badge label="Pending deploy" color="warning" />
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{dirty.anyLocalEdit && ( {dirty.anyLocalEdit && (
<Button <Button
@@ -417,6 +425,40 @@ export default function AppDeploymentPage() {
enabled={primaryEnabled} enabled={primaryEnabled}
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave} onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
/> />
{app && latestDeployment && (
latestDeployment.status === 'RUNNING'
|| latestDeployment.status === 'STARTING'
|| latestDeployment.status === 'DEGRADED'
) && (
<Button size="sm" variant="danger" onClick={() => handleStop(latestDeployment.id)}>
Stop
</Button>
)}
{app && latestDeployment && latestDeployment.status === 'STOPPED' && (
<Button
size="sm"
variant="secondary"
onClick={() => {
setTab('deployment');
createDeployment
.mutateAsync({
envSlug: selectedEnv!,
appSlug: app.slug,
appVersionId: latestDeployment.appVersionId,
})
.catch((e: unknown) =>
toast({
title: 'Start failed',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error',
duration: 86_400_000,
}),
);
}}
>
Start
</Button>
)}
{app && ( {app && (
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}> <Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>
Delete App Delete App
@@ -493,26 +535,6 @@ export default function AppDeploymentPage() {
appSlug={app.slug} appSlug={app.slug}
envSlug={env.slug} envSlug={env.slug}
externalUrl={externalUrl} externalUrl={externalUrl}
onStop={handleStop}
onStart={(deploymentId) => {
// Re-deploy from a specific historical deployment's version
const d = deployments.find((dep) => dep.id === deploymentId);
if (d && selectedEnv && app) {
setTab('deployment');
createDeployment.mutateAsync({
envSlug: selectedEnv,
appSlug: app.slug,
appVersionId: d.appVersionId,
}).catch((e: unknown) =>
toast({
title: 'Start failed',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error',
duration: 86_400_000,
}),
);
}
}}
/> />
)} )}
{tab === 'deployment' && !app && ( {tab === 'deployment' && !app && (