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

+2
View File
@@ -121,3 +121,5 @@ bin
# Local reference checkouts (not project code) # Local reference checkouts (not project code)
ref/ ref/
.playwright-mcp/
canvas-*.png
+33 -9
View File
@@ -8,9 +8,11 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strings" "strings"
"time" "time"
"github.com/docker/docker/api/types/filters"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -73,7 +75,21 @@ func handleGetLogs(c *gin.Context) {
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
return return
} }
containerName := fmt.Sprintf("containr-%s", serviceID) // Replicas are named containr-<serviceID>-<n>; resolve them by label and
// merge logs across all of them.
ctx := context.Background()
containers, err := client.ListContainersFiltered(ctx, filters.NewArgs(
filters.Arg("label", "containr.service="+serviceID.String()),
), false)
if err != nil {
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
return
}
if len(containers) == 0 {
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
return
}
sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] })
logOpts := docker.LogOptions{ logOpts := docker.LogOptions{
Stdout: true, Stdout: true,
@@ -83,17 +99,25 @@ func handleGetLogs(c *gin.Context) {
Timestamps: true, Timestamps: true,
} }
ctx := context.Background() var readers []io.ReadCloser
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts) var streams []io.Reader
if err != nil { for _, ctr := range containers {
if isDockerNotFoundError(err) { r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts)
respondEmptyServiceLogs(c, "No container logs are available for this service yet.") if rerr == nil {
return readers = append(readers, r)
streams = append(streams, r)
} }
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") }
if len(readers) == 0 {
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
return return
} }
defer logsReader.Close() logsReader := io.MultiReader(streams...)
defer func() {
for _, r := range readers {
_ = r.Close()
}
}()
if follow { if follow {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
+3 -1
View File
@@ -372,7 +372,9 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) {
service.Status = state.Status service.Status = state.Status
_, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID)
} }
if len(state.URLs) > 0 && service.Domain == "" { if service.Domain != "" {
service.PublicURL = "https://" + service.Domain
} else if len(state.URLs) > 0 {
service.PublicURL = state.URLs[0] service.PublicURL = state.URLs[0]
} }
} }
+12 -4
View File
@@ -97,6 +97,15 @@ func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID
}, },
}) })
if err != nil { if err != nil {
// Concurrent reconciles race on create; re-resolve before failing.
networks, lerr := de.dockerClient.ListNetworks(ctx)
if lerr == nil {
for _, n := range networks {
if n.Name == name {
return name, nil
}
}
}
return "", fmt.Errorf("create project network: %w", err) return "", fmt.Errorf("create project network: %w", err)
} }
return name, nil return name, nil
@@ -231,10 +240,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec,
if spec.Port > 0 { if spec.Port > 0 {
port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port))
cfg.ExposedPorts = nat.PortSet{port: struct{}{}} cfg.ExposedPorts = nat.PortSet{port: struct{}{}}
if spec.Domain == "" { // Always publish an ephemeral host port for direct access; a configured
// No edge router: publish an ephemeral host port for direct access. // domain is additionally routed via Traefik when the edge network exists.
cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}} cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}}
}
if spec.HealthPath != "" { if spec.HealthPath != "" {
cfg.Healthcheck = &container.HealthConfig{ cfg.Healthcheck = &container.HealthConfig{
Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)}, Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)},
@@ -288,7 +288,11 @@ export function ServiceDetailPage() {
}); });
const manualScaleMutation = useMutation({ const manualScaleMutation = useMutation({
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas), mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
onSuccess: invalidateScaling, onSuccess: () => {
invalidateScaling();
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services'] });
},
}); });
// --- Console --- // --- Console ---
@@ -589,9 +593,11 @@ export function ServiceDetailPage() {
<div> <div>
<div className="flex items-center" style={{ gap: '10px' }}> <div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span> <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' && <span className="live-dot" />}
{service.status === 'running' ? 'Active' : 'Stopped'} {service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
</span> </span>
</div> </div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}> <div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
@@ -628,8 +634,8 @@ export function ServiceDetailPage() {
<> <>
<button <button
onClick={() => stopMutation.mutate()} onClick={() => stopMutation.mutate()}
disabled={stopMutation.isPending || service.status !== 'running'} disabled={stopMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`} 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"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -639,8 +645,8 @@ export function ServiceDetailPage() {
</button> </button>
<button <button
onClick={() => restartMutation.mutate()} onClick={() => restartMutation.mutate()}
disabled={restartMutation.isPending || service.status !== 'running'} disabled={restartMutation.isPending || (service.status !== 'running' && service.status !== 'degraded')}
className={`btn-restart ${service.status !== 'running' ? 'disabled' : ''}`} 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"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/> <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"> <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-xs text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="text-xl font-semibold text-[var(--text-primary)]"> <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)]"> <span className="text-sm font-normal text-[var(--text-secondary)]">
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? '—'} desired {' '}/ {scalingStateQuery.data?.DesiredReplicas ?? runtimeQuery.data?.desired ?? service?.replicas ?? '—'} desired
</span> </span>
</p> </p>
</div> </div>
@@ -1637,7 +1645,7 @@ export function ServiceDetailPage() {
</div> </div>
<button <button
type="button" type="button"
disabled={manualScaleMutation.isPending || !scaleReplicas || !scalingStateQuery.data} disabled={manualScaleMutation.isPending || !scaleReplicas}
onClick={() => manualScaleMutation.mutate(Number(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" className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
> >
@@ -17,6 +17,7 @@ import {
createDeployment, createDeployment,
deleteService, deleteService,
restartService, restartService,
startService,
stopService, stopService,
} from '@/lib/api-client'; } from '@/lib/api-client';
import { useToast } from '@/shared/hooks/use-toast'; import { useToast } from '@/shared/hooks/use-toast';
@@ -118,27 +119,40 @@ function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[],
return [...groups, ...serviceNodes]; return [...groups, ...serviceNodes];
} }
function toFlowEdges(links: ReturnType<typeof inferAutoConnections>): CanvasEdge[] { function toFlowEdges(
return links.map((link) => ({ links: ReturnType<typeof inferAutoConnections>,
id: link.edge.id, positionOf: (serviceId: string) => { x: number; y: number } | undefined,
source: link.edge.sourceServiceId, ): CanvasEdge[] {
target: link.edge.targetServiceId, return links.map((link) => {
animated: false, const source = positionOf(link.edge.sourceServiceId);
data: { const target = positionOf(link.edge.targetServiceId);
reasons: link.reasons, // Choose handle sides by relative position so edges flow forward
}, // instead of looping back through the canvas.
markerEnd: { const forward = !source || !target || source.x <= target.x;
type: MarkerType.ArrowClosed,
color: 'var(--accent-secondary)', return {
width: 14, id: link.edge.id,
height: 14, source: link.edge.sourceServiceId,
}, target: link.edge.targetServiceId,
style: { sourceHandle: forward ? 's-r' : 's-l',
stroke: 'var(--accent-primary)', targetHandle: forward ? 't-l' : 't-r',
strokeWidth: 2, animated: false,
}, data: {
className: 'edge-premium', reasons: link.reasons,
})); },
markerEnd: {
type: MarkerType.ArrowClosed,
color: 'var(--accent-secondary)',
width: 14,
height: 14,
},
style: {
stroke: 'var(--accent-primary)',
strokeWidth: 2,
},
className: 'edge-premium',
};
});
} }
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata { function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
@@ -210,10 +224,12 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
() => inferAutoConnections(services, variablesByService), () => inferAutoConnections(services, variablesByService),
[services, variablesByService], [services, variablesByService],
); );
const edges = useMemo( const edges = useMemo(() => {
() => toFlowEdges(inferredLinks).filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)), const positionOf = (serviceId: string) => nodes.find((node) => node.id === serviceId)?.position;
[inferredLinks, visibleIds], return toFlowEdges(inferredLinks, positionOf).filter(
); (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
);
}, [inferredLinks, visibleIds, nodes]);
const invalidateServices = useCallback(() => { const invalidateServices = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] }); void queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
@@ -231,6 +247,14 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
}, },
onError: actionError, onError: actionError,
}); });
const startMutation = useMutation({
mutationFn: (serviceId: string) => startService(serviceId),
onSuccess: () => {
toast.showToast('Service started', 'success');
invalidateServices();
},
onError: actionError,
});
const restartMutation = useMutation({ const restartMutation = useMutation({
mutationFn: (serviceId: string) => restartService(serviceId), mutationFn: (serviceId: string) => restartService(serviceId),
onSuccess: () => { onSuccess: () => {
@@ -256,7 +280,11 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
onError: actionError, onError: actionError,
}); });
const actionPending = const actionPending =
deployMutation.isPending || restartMutation.isPending || stopMutation.isPending || deleteMutation.isPending; deployMutation.isPending ||
startMutation.isPending ||
restartMutation.isPending ||
stopMutation.isPending ||
deleteMutation.isPending;
useEffect(() => { useEffect(() => {
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null; const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
@@ -453,11 +481,11 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
event.preventDefault(); event.preventDefault();
const bounds = wrapperRef.current?.getBoundingClientRect(); const bounds = wrapperRef.current?.getBoundingClientRect();
setSelectedServiceId(node.id); setSelectedServiceId(node.id);
setContextMenu({ const menuW = 190;
serviceId: node.id, const menuH = 230;
x: event.clientX - (bounds?.left ?? 0), const x = Math.min(event.clientX - (bounds?.left ?? 0), Math.max(0, (bounds?.width ?? 0) - menuW));
y: event.clientY - (bounds?.top ?? 0), const y = Math.min(event.clientY - (bounds?.top ?? 0), Math.max(0, (bounds?.height ?? 0) - menuH));
}); setContextMenu({ serviceId: node.id, x, y });
}, },
[], [],
); );
@@ -635,15 +663,15 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<ArrowUpRight size={13} /> <ArrowUpRight size={13} />
Open service Open service
</button> </button>
{contextService.domain && ( {(contextService.domain || contextService.publicUrl) && (
<a <a
href={`https://${contextService.domain}`} href={contextService.domain ? `https://${contextService.domain}` : contextService.publicUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors"
> >
<Globe size={13} /> <Globe size={13} />
Visit {contextService.domain} Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')}
</a> </a>
)} )}
<button <button
@@ -658,6 +686,20 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
<Rocket size={13} /> <Rocket size={13} />
Deploy Deploy
</button> </button>
{contextService.status !== 'running' && (
<button
type="button"
disabled={actionPending}
onClick={() => {
setContextMenu(null);
startMutation.mutate(contextService.id);
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
<Play size={13} />
Start
</button>
)}
<button <button
type="button" type="button"
disabled={actionPending || contextService.status !== 'running'} disabled={actionPending || contextService.status !== 'running'}
@@ -47,6 +47,8 @@ function statusLabel(status: string): string {
switch (status) { switch (status) {
case 'running': case 'running':
return 'Online'; return 'Online';
case 'degraded':
return 'Degraded';
case 'building': case 'building':
return 'Deploying'; return 'Deploying';
case 'failed': case 'failed':
@@ -56,11 +58,25 @@ function statusLabel(status: string): string {
} }
} }
function statusColor(status: string): string {
switch (status) {
case 'running':
return 'var(--success)';
case 'degraded':
case 'building':
return 'var(--warning)';
case 'failed':
return 'var(--error)';
default:
return 'var(--text-tertiary)';
}
}
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) { export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected } = data; const { service, selected } = data;
const typeColor = serviceTypeColor(service.type); const typeColor = serviceTypeColor(service.type);
const isRunning = service.status === 'running';
const replicas = service.replicas ?? 0; const replicas = service.replicas ?? 0;
const stateColor = statusColor(service.status);
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl; const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name; const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
@@ -79,12 +95,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
style={{ background: `${typeColor}10` }} style={{ background: `${typeColor}10` }}
/> />
<Handle type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" /> <Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" /> <Handle id="t-r" type="target" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle id="s-l" type="source" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
<Handle id="s-r" type="source" position={Position.Right} className="!opacity-0 !w-2 !h-2 !border-0" />
<div <div
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`} className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${service.status === 'running' ? 'opacity-100' : 'opacity-40'}`}
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }} style={{ background: stateColor }}
/> />
<div className="relative p-4"> <div className="relative p-4">
@@ -148,14 +166,15 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`status-dot ${serviceStatusClass(service.status)}`} /> <span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span <span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
>
{statusLabel(service.status)} {statusLabel(service.status)}
</span> </span>
</div> </div>
<span className="text-[10px] text-[var(--text-tertiary)]">Right-click for actions</span> {service.image && (
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
{service.image}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
+17
View File
@@ -381,6 +381,10 @@ textarea {
animation: pulse-ring 2s ease-out infinite; animation: pulse-ring 2s ease-out infinite;
} }
.status-degraded {
background: var(--warning);
}
.status-building { .status-building {
background: var(--warning); background: var(--warning);
animation: pulse-dot 1.5s ease-in-out infinite; animation: pulse-dot 1.5s ease-in-out infinite;
@@ -979,6 +983,19 @@ textarea {
transition: all 0.3s; transition: all 0.3s;
} }
.badge-degraded {
padding: 2px 8px;
border-radius: 6px;
background: rgba(242, 201, 76, 0.15);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.7px;
color: var(--warning);
text-transform: uppercase;
display: inline-flex;
align-items: center;
}
/* Badge Stopped - self.html exact match */ /* Badge Stopped - self.html exact match */
.badge-stopped { .badge-stopped {
padding: 2px 8px; padding: 2px 8px;
+3 -1
View File
@@ -1,6 +1,6 @@
import type { components, paths } from '@/generated/api-types'; import type { components, paths } from '@/generated/api-types';
export type ServiceStatus = 'running' | 'stopped' | 'building' | 'failed' | 'unknown'; export type ServiceStatus = 'running' | 'degraded' | 'stopped' | 'building' | 'failed' | 'unknown';
export type ProjectStats = components['schemas']['ProjectStats']; export type ProjectStats = components['schemas']['ProjectStats'];
@@ -1319,6 +1319,8 @@ export function serviceStatusClass(status: ServiceStatus): string {
switch (status) { switch (status) {
case 'running': case 'running':
return 'status-running'; return 'status-running';
case 'degraded':
return 'status-degraded';
case 'building': case 'building':
return 'status-building'; return 'status-building';
case 'failed': case 'failed':