mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
1d5b99e1ba
commit
dfae6ef0bd
3 files changed
+55
-17
No files matched your search
+5
-2
@@ -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({
|
||||||
|
|||||||
Reference in new issue
Block a user