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;
|
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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
<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 }}>
|
<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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user