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
keys). Per-environment values are already covered by the schema: each
environment owns its service instances, and variables are per-service.
- [ ] **Deploy lifecycle polish**: build → deploy status propagation to canvas
node, failed-deploy surfacing with log tail link, rollback confirmation.
- [x] **Deploy lifecycle polish**: backend already writes deploy progress to
`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
port must produce a clear error, not a silent failure.
- [ ] 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 [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState('');
const [logDeploymentId, setLogDeploymentId] = useState<string | null>(null);
const projectQuery = useQuery({
queryKey: ['project', projectId],
@@ -148,11 +149,14 @@ export function ServiceDetailPage() {
return deployments[0] ?? null;
}, [deploymentsQuery.data, isDemoMode]);
const logsDeployment =
(deploymentsQuery.data ?? []).find((d) => d.id === logDeploymentId) ?? latestDeployment;
const deploymentLogsQuery = useQuery({
queryKey: ['deployment-logs', latestDeployment?.id],
queryFn: () => getDeploymentLogs(latestDeployment!.id, { type: 'all' }),
queryKey: ['deployment-logs', logsDeployment?.id],
queryFn: () => getDeploymentLogs(logsDeployment!.id, { type: 'all' }),
enabled:
Boolean(latestDeployment?.id) &&
Boolean(logsDeployment?.id) &&
!isDemoMode &&
activeSection === 'logs',
});
@@ -775,7 +779,7 @@ export function ServiceDetailPage() {
<button
onClick={() => {
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"
>
@@ -824,13 +828,25 @@ export function ServiceDetailPage() {
)}
</div>
{!isDemoMode && latestDeployment && (
{!isDemoMode && logsDeployment && (
<div className="mt-6 panel-soft p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Latest Deployment</p>
<StatusBadge status={latestDeployment.status} />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">
{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>
<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)]">
{deploymentLogsQuery.isLoading
? 'Loading...'
@@ -1109,13 +1125,28 @@ export function ServiceDetailPage() {
{deployment.createdAt ? formatRelative(deployment.createdAt) : '—'}
</td>
<td className="px-4 py-3">
<button
onClick={() => 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 className="flex items-center gap-2">
<button
onClick={() => {
setLogDeploymentId(deployment.id);
setActiveSection('logs');
}}
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>
</tr>
))
@@ -491,6 +491,10 @@ export function ProjectWorkspacePage() {
queryKey: ['project-services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId) && !isDemoMode,
refetchInterval: (query) =>
query.state.data?.some((s) => ['building', 'deploying', 'pending', 'rolling_back'].includes(s.status))
? 3000
: 15000,
});
const createServiceMutation = useMutation({