mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
04c3b99531
commit
b7e2e93261
9 files changed
+193
-69
No files matched your search
@@ -121,3 +121,5 @@ bin
|
|||||||
|
|
||||||
# Local reference checkouts (not project code)
|
# Local reference checkouts (not project code)
|
||||||
ref/
|
ref/
|
||||||
|
.playwright-mcp/
|
||||||
|
canvas-*.png
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in new issue
Block a user