feat(deploy): lifecycle polish — canvas polling, log links, rollback confirm

Canvas nodes were static: servicesQuery had no refetchInterval, so
build → deploy status never reached the node without a manual refresh.
It now polls at 3s while any service is transitional and 15s otherwise.

Deployment history rows link directly into that deployment's log tail
(service detail logs section, with a "show latest" reset), and rollback
now asks for confirmation before firing — it previously fired on a
single click.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 14:55:25 +02:00
1 parent 1d5b99e1ba
commit dfae6ef0bd
3 files changed
+55 -17

No files matched your search

+5 -2
View File
@@ -225,8 +225,11 @@ pieces exist; this phase closes the gaps that block real usage.
prefix, quoted values), inline validation (required / invalid / duplicate prefix, quoted values), inline validation (required / invalid / duplicate
keys). Per-environment values are already covered by the schema: each keys). Per-environment values are already covered by the schema: each
environment owns its service instances, and variables are per-service. environment owns its service instances, and variables are per-service.
- [ ] **Deploy lifecycle polish**: build → deploy status propagation to canvas - [x] **Deploy lifecycle polish**: backend already writes deploy progress to
node, failed-deploy surfacing with log tail link, rollback confirmation. `services.status`; the canvas now polls faster while any service is in a
transitional state so build → deploy propagates to nodes, deployment
history rows link straight into that deployment's log tail, and rollback
asks for confirmation before firing.
- [ ] **Node/port collision handling**: two services claiming the same host - [ ] **Node/port collision handling**: two services claiming the same host
port must produce a clear error, not a silent failure. port must produce a clear error, not a silent failure.
- [ ] Auth hardening: e2e verify bootstrap gate (first user registers, second - [ ] Auth hardening: e2e verify bootstrap gate (first user registers, second
@@ -96,6 +96,7 @@ export function ServiceDetailPage() {
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null); const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
const [bulkOpen, setBulkOpen] = useState(false); const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState(''); const [bulkText, setBulkText] = useState('');
const [logDeploymentId, setLogDeploymentId] = useState<string | null>(null);
const projectQuery = useQuery({ const projectQuery = useQuery({
queryKey: ['project', projectId], queryKey: ['project', projectId],
@@ -148,11 +149,14 @@ export function ServiceDetailPage() {
return deployments[0] ?? null; return deployments[0] ?? null;
}, [deploymentsQuery.data, isDemoMode]); }, [deploymentsQuery.data, isDemoMode]);
const logsDeployment =
(deploymentsQuery.data ?? []).find((d) => d.id === logDeploymentId) ?? latestDeployment;
const deploymentLogsQuery = useQuery({ const deploymentLogsQuery = useQuery({
queryKey: ['deployment-logs', latestDeployment?.id], queryKey: ['deployment-logs', logsDeployment?.id],
queryFn: () => getDeploymentLogs(latestDeployment!.id, { type: 'all' }), queryFn: () => getDeploymentLogs(logsDeployment!.id, { type: 'all' }),
enabled: enabled:
Boolean(latestDeployment?.id) && Boolean(logsDeployment?.id) &&
!isDemoMode && !isDemoMode &&
activeSection === 'logs', activeSection === 'logs',
}); });
@@ -775,7 +779,7 @@ export function ServiceDetailPage() {
<button <button
onClick={() => { onClick={() => {
serviceLogsQuery.refetch(); serviceLogsQuery.refetch();
if (latestDeployment?.id) deploymentLogsQuery.refetch(); if (logsDeployment?.id) deploymentLogsQuery.refetch();
}} }}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors" className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
> >
@@ -824,13 +828,25 @@ export function ServiceDetailPage() {
)} )}
</div> </div>
{!isDemoMode && latestDeployment && ( {!isDemoMode && logsDeployment && (
<div className="mt-6 panel-soft p-4"> <div className="mt-6 panel-soft p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Latest Deployment</p> <p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">
<StatusBadge status={latestDeployment.status} /> {logsDeployment.id === latestDeployment?.id ? 'Latest Deployment' : 'Deployment'}
</p>
<div className="flex items-center gap-3">
{logsDeployment.id !== latestDeployment?.id && (
<button
onClick={() => setLogDeploymentId(null)}
className="text-xs text-[var(--accent-primary)] hover:underline"
>
Show latest
</button>
)}
<StatusBadge status={logsDeployment.status} />
</div>
</div> </div>
<p className="text-xs text-[var(--text-tertiary)] mono">{latestDeployment.id}</p> <p className="text-xs text-[var(--text-tertiary)] mono">{logsDeployment.id}</p>
<pre className="mono mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-[var(--text-secondary)]"> <pre className="mono mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-[var(--text-secondary)]">
{deploymentLogsQuery.isLoading {deploymentLogsQuery.isLoading
? 'Loading...' ? 'Loading...'
@@ -1109,13 +1125,28 @@ export function ServiceDetailPage() {
{deployment.createdAt ? formatRelative(deployment.createdAt) : '—'} {deployment.createdAt ? formatRelative(deployment.createdAt) : '—'}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<button <div className="flex items-center gap-2">
onClick={() => rollbackMutation.mutate(deployment.id)} <button
disabled={!canRollback(deployment.status) || rollbackMutation.isPending} onClick={() => {
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors" setLogDeploymentId(deployment.id);
> setActiveSection('logs');
{rollbackMutation.isPending && rollbackMutation.variables === deployment.id ? '...' : 'Rollback'} }}
</button> className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Logs
</button>
<button
onClick={() => {
if (window.confirm(`Rollback to deployment ${deployment.id.slice(0, 8)}?`)) {
rollbackMutation.mutate(deployment.id);
}
}}
disabled={!canRollback(deployment.status) || rollbackMutation.isPending}
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
>
{rollbackMutation.isPending && rollbackMutation.variables === deployment.id ? '...' : 'Rollback'}
</button>
</div>
</td> </td>
</tr> </tr>
)) ))
@@ -491,6 +491,10 @@ export function ProjectWorkspacePage() {
queryKey: ['project-services', projectId], queryKey: ['project-services', projectId],
queryFn: () => listServicesByProject(projectId), queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId) && !isDemoMode, enabled: Boolean(projectId) && !isDemoMode,
refetchInterval: (query) =>
query.state.data?.some((s) => ['building', 'deploying', 'pending', 'rolling_back'].includes(s.status))
? 3000
: 15000,
}); });
const createServiceMutation = useMutation({ const createServiceMutation = useMutation({