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:
@@ -12,11 +12,9 @@ interface Props {
|
||||
appSlug: string;
|
||||
envSlug: 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
|
||||
.slice()
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
|
||||
@@ -33,8 +31,6 @@ export function DeploymentTab({ deployments, versions, appSlug, envSlug, externa
|
||||
deployment={latest}
|
||||
version={version}
|
||||
externalUrl={externalUrl}
|
||||
onStop={() => onStop(latest.id)}
|
||||
onStart={() => onStart(latest.id)}
|
||||
/>
|
||||
{latest.status === 'STARTING' && (
|
||||
<DeploymentProgress currentStage={latest.deployStage} failed={false} />
|
||||
|
||||
@@ -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 { timeAgo } from '../../../../utils/format-utils';
|
||||
import styles from '../AppDeploymentPage.module.css';
|
||||
@@ -13,21 +13,13 @@ const DEPLOY_STATUS_DOT = {
|
||||
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
|
||||
} 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 {
|
||||
deployment: Deployment;
|
||||
version: AppVersion | null;
|
||||
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 total = deployment.replicaStates?.length ?? 0;
|
||||
|
||||
@@ -56,12 +48,6 @@ export function StatusCard({ deployment, version, externalUrl, onStop, onStart }
|
||||
{deployment.errorMessage}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router';
|
||||
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 { useEnvironments } from '../../../api/queries/admin/environments';
|
||||
import {
|
||||
@@ -60,6 +60,9 @@ export default function AppDeploymentPage() {
|
||||
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
|
||||
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? 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: dirtyState, isLoading: dirtyLoading } = useDirtyState(selectedEnv, app?.slug);
|
||||
@@ -398,7 +401,12 @@ export default function AppDeploymentPage() {
|
||||
<div className={styles.container}>
|
||||
{/* ── Page header ── */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<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 }}>
|
||||
{dirty.anyLocalEdit && (
|
||||
<Button
|
||||
@@ -417,6 +425,40 @@ export default function AppDeploymentPage() {
|
||||
enabled={primaryEnabled}
|
||||
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 && (
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>
|
||||
Delete App
|
||||
@@ -493,26 +535,6 @@ export default function AppDeploymentPage() {
|
||||
appSlug={app.slug}
|
||||
envSlug={env.slug}
|
||||
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 && (
|
||||
|
||||
Reference in New Issue
Block a user