fix: polish service runtime UX and harden reconcile edge cases

Backend:
- tolerate concurrent project-network creation (re-resolve on conflict)
- always publish an ephemeral host port when a port is configured, even
  with a domain set — Traefik routes on top when the edge network exists
- public_url now prefers the configured domain, falls back to the first
  published port
- service logs resolve replicas by containr.service label and merge all
  replica streams — previously queried a nonexistent containr-<id> name

Canvas/detail:
- degraded status renders amber end-to-end (node card, detail badge,
  status dot) instead of falling through to "Stopped"
- edges pick handle sides by relative node position — no more backward
  loops when the source sits right of the target
- context menu gains Start for stopped services, Visit uses the
  published URL when no domain is set, menu clamps to canvas bounds
- node cards show the image tag instead of a redundant hint
- scaling tab falls back to live runtime replica counts when no
  autoscaler state exists; manual scale no longer requires it

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-21 20:29:31 +02:00
1 parent 04c3b99531
commit b7e2e93261
9 files changed
+193 -69

No files matched your search

@@ -288,7 +288,11 @@ export function ServiceDetailPage() {
});
const manualScaleMutation = useMutation({
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
onSuccess: invalidateScaling,
onSuccess: () => {
invalidateScaling();
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services'] });
},
});
// --- Console ---
@@ -589,9 +593,11 @@ export function ServiceDetailPage() {
<div>
<div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
<span className={`badge-${service.status === 'running' ? 'active' : 'stopped'}`}>
<span
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
>
{service.status === 'running' && <span className="live-dot" />}
{service.status === 'running' ? 'Active' : 'Stopped'}
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
</span>
</div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
@@ -628,8 +634,8 @@ export function ServiceDetailPage() {
<>
<button
onClick={() => stopMutation.mutate()}
disabled={stopMutation.isPending || service.status !== 'running'}
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`}
disabled={stopMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
className={`btn-stop ${service.status !== 'running' && service.status !== 'degraded' ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/>
@@ -639,8 +645,8 @@ export function ServiceDetailPage() {
</button>
<button
onClick={() => restartMutation.mutate()}
disabled={restartMutation.isPending || service.status !== 'running'}
className={`btn-restart ${service.status !== 'running' ? 'disabled' : ''}`}
disabled={restartMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
className={`btn-restart ${service.status !== 'running' && service.status !== 'degraded' ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
@@ -1595,9 +1601,11 @@ export function ServiceDetailPage() {
<div className="rounded-[var(--radius-md)] border border-[var(--border-primary)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingStateQuery.data?.CurrentReplicas ?? '—'}
{scalingStateQuery.data?.CurrentReplicas ??
runtimeQuery.data?.containers.filter((c) => c.state === 'running').length ??
'—'}
<span className="text-sm font-normal text-[var(--text-secondary)]">
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? '—'} desired
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? runtimeQuery.data?.desired ?? service?.replicas ?? '—'} desired
</span>
</p>
</div>
@@ -1637,7 +1645,7 @@ export function ServiceDetailPage() {
</div>
<button
type="button"
disabled={manualScaleMutation.isPending || !scaleReplicas || !scalingStateQuery.data}
disabled={manualScaleMutation.isPending || !scaleReplicas}
onClick={() => manualScaleMutation.mutate(Number(scaleReplicas))}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>