Files
Containr/app/frontend/src/features/service/pages/ServiceDetailPage.tsx
T
Tomáš DvořákandDevin dd5ce80ce2 feat(scheduling): replica spread + resource-aware auto placement (#56)
services.spread distributes replicas across every online, schedulable
agent (replica i → agents[i mod n], deterministic). auto placement now
orders by real headroom — memory utilisation, cpu usage, then container
count — instead of count alone.

Lifecycle (start/stop/restart/delete) and GET /runtime are now driven
by container_instances inventory, so remote replicas are visible and
tear down wherever they run — covers spread, pins, and stale pin
leftovers. Spread and explicit pins are mutually exclusive.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 17:03:26 +02:00

2943 lines
154 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createDeployment,
deleteService,
getProjectById,
getDeploymentLogs,
getServiceById,
getServiceMetrics,
listDeployments,
listServiceLogs,
listServiceVariables,
listCronJobs,
createCronJob,
updateCronJob,
deleteCronJob,
listCronExecutions,
triggerCronJob,
listPreviewEnvironments,
createPreviewEnvironment,
deletePreviewEnvironment,
promotePreviewEnvironment,
getScalingPolicy,
setScalingPolicy,
deleteScalingPolicy,
getServiceScalingState,
manualScaleService,
rollbackDeployment,
cancelDeployment,
updateServiceVariables,
updateService,
restartService,
stopService,
sleepService,
wakeService,
getServiceRuntime,
getServiceEnvCheck,
type CronJobEntity,
type ServiceVolume,
type ServiceDomain,
type DomainCheckResult,
listServiceDomains,
addServiceDomain,
removeServiceDomain,
setDefaultServiceDomain,
checkServiceDomains,
cloneService,
moveService,
listProjects,
listNodeOptions,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
import { useAuthSession } from '@/lib/use-auth-session';
import { getCurrentUserProfile } from '@/lib/api-client';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
import { EnhancedMetricCard, LineAreaChart, DonutChart } from '@/shared/components';
import { ServiceTerminal } from '@/features/service/components/ServiceTerminal';
import {
Activity,
FileText,
Settings,
Sliders,
KeyRound,
Plus,
Trash2,
ClipboardPaste,
Save,
Check,
X,
Loader2,
Clock,
Cpu,
Layers,
MemoryStick,
Zap,
Timer,
Sparkles,
RefreshCw,
Box,
Ban,
Play,
ChevronDown,
GitPullRequest,
ExternalLink,
Terminal,
} from 'lucide-react';
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings';
// Sections marked manageOnly are hidden from anonymous visitors — they
// expose authenticated or owner-scoped data (logs, variables, exec).
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity; manageOnly?: boolean }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText, manageOnly: true },
{ key: 'config', label: 'Config', icon: Sliders },
{ key: 'variables', label: 'Variables', icon: KeyRound, manageOnly: true },
{ key: 'cron', label: 'Cron', icon: Clock },
{ key: 'previews', label: 'Previews', icon: GitPullRequest },
{ key: 'scaling', label: 'Scaling', icon: Layers },
{ key: 'console', label: 'Console', icon: Terminal, manageOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings, manageOnly: true },
];
function metricStatus(percent: number): 'good' | 'average' | 'warning' {
if (percent < 50) return 'good';
if (percent < 80) return 'average';
return 'warning';
}
const statusLabel = { good: 'Good', average: 'Average', warning: 'High' } as const;
function StatusBadge({ status }: { status: string }) {
const config = {
running: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
deployed: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
queued: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Clock, animate: false },
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Ban, animate: false },
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status.replace('_', ' ')}
</div>
);
}
export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const isDemoMode = useDemoMode();
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
const signedIn = isDemoMode || Boolean(sessionQuery.data);
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
enabled: signedIn && !isDemoMode,
retry: false,
});
const isAdmin = Boolean(profileQuery.data?.isAdmin);
const [activeSection, setActiveSection] = useState<ServiceSection>(() => {
const requested = searchParams.get('section');
return sectionItems.some((item) => item.key === requested) ? (requested as ServiceSection) : 'metrics';
});
const [logTail, setLogTail] = useState('100');
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],
queryFn: () => getProjectById(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const serviceQuery = useQuery({
queryKey: ['service', serviceId],
queryFn: () => getServiceById(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
});
const deploymentsQuery = useQuery({
queryKey: ['service-deployments', serviceId],
queryFn: () => listDeployments(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
refetchInterval: 4000,
});
const variablesQuery = useQuery({
queryKey: ['service-variables', serviceId],
queryFn: () => listServiceVariables(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
});
const envCheckQuery = useQuery({
queryKey: ['service-env-check', serviceId],
queryFn: () => getServiceEnvCheck(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'variables',
});
const saveVariablesMutation = useMutation({
mutationFn: (rows: VariableDraft[]) =>
updateServiceVariables(
serviceId,
rows.map((row) => ({ key: row.key.trim(), value: row.value, is_secret: row.isSecret })),
),
onSuccess: (data) => {
queryClient.setQueryData(['service-variables', serviceId], data);
setVarDrafts(null);
},
});
// --- Cron jobs ---
const [cronForm, setCronForm] = useState<{
name: string; schedule: string; command: string; timezone: string; enabled: boolean; retention: number;
} | null>(null);
const [editingCronId, setEditingCronId] = useState<string | null>(null);
const [historyCronId, setHistoryCronId] = useState<string | null>(null);
const cronJobsQuery = useQuery({
queryKey: ['service-cron-jobs', serviceId],
queryFn: () => listCronJobs(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'cron',
});
const cronExecutionsQuery = useQuery({
queryKey: ['cron-executions', historyCronId],
queryFn: () => listCronExecutions(historyCronId as string),
enabled: Boolean(historyCronId) && !isDemoMode && activeSection === 'cron',
});
const invalidateCron = () => queryClient.invalidateQueries({ queryKey: ['service-cron-jobs', serviceId] });
const saveCronMutation = useMutation({
mutationFn: async () => {
if (!cronForm) return;
const payload = {
name: cronForm.name.trim(),
schedule: cronForm.schedule.trim(),
command: cronForm.command.trim(),
timezone: cronForm.timezone.trim() || 'UTC',
enabled: cronForm.enabled,
retention: cronForm.retention,
};
if (editingCronId) {
await updateCronJob(editingCronId, payload);
} else {
await createCronJob({ project_id: projectId, service_id: serviceId, ...payload });
}
},
onSuccess: () => {
setCronForm(null);
setEditingCronId(null);
invalidateCron();
},
});
const deleteCronMutation = useMutation({
mutationFn: (id: string) => deleteCronJob(id),
onSuccess: invalidateCron,
});
const toggleCronMutation = useMutation({
mutationFn: (job: CronJobEntity) => updateCronJob(job.id as string, { enabled: !job.enabled }),
onSuccess: invalidateCron,
});
// --- Preview environments ---
const [previewForm, setPreviewForm] = useState<{ branch: string; prNumber: string; ttlHours: string } | null>(null);
const previewsQuery = useQuery({
queryKey: ['project-preview-envs', projectId],
queryFn: () => listPreviewEnvironments(projectId),
enabled: Boolean(projectId) && !isDemoMode && activeSection === 'previews',
refetchInterval: 15_000,
});
const invalidatePreviews = () => queryClient.invalidateQueries({ queryKey: ['project-preview-envs', projectId] });
const createPreviewMutation = useMutation({
mutationFn: async () => {
if (!previewForm) return;
await createPreviewEnvironment(projectId, {
project_id: projectId,
service_id: serviceId,
branch_name: previewForm.branch.trim(),
pr_number: previewForm.prNumber ? Number(previewForm.prNumber) : undefined,
ttl_hours: previewForm.ttlHours ? Number(previewForm.ttlHours) : undefined,
});
},
onSuccess: () => { setPreviewForm(null); invalidatePreviews(); },
});
const deletePreviewMutation = useMutation({
mutationFn: (id: string) => deletePreviewEnvironment(id),
onSuccess: invalidatePreviews,
});
const promotePreviewMutation = useMutation({
mutationFn: (id: string) => promotePreviewEnvironment(id, { target_environment: 'production' }),
onSuccess: invalidatePreviews,
});
const previews = (previewsQuery.data ?? []).filter((p) => p.service_id === serviceId);
// --- Scaling ---
const [scalingForm, setScalingForm] = useState<{
min: string; max: string; targetCpu: string; targetMemory: string; enabled: boolean;
} | null>(null);
const [scaleReplicas, setScaleReplicas] = useState('');
const scalingPolicyQuery = useQuery({
queryKey: ['scaling-policy', serviceId],
queryFn: () => getScalingPolicy(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'scaling',
});
const scalingStateQuery = useQuery({
queryKey: ['scaling-state', serviceId],
queryFn: () => getServiceScalingState(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'scaling',
refetchInterval: 15_000,
});
const invalidateScaling = () => {
queryClient.invalidateQueries({ queryKey: ['scaling-policy', serviceId] });
queryClient.invalidateQueries({ queryKey: ['scaling-state', serviceId] });
};
const saveScalingMutation = useMutation({
mutationFn: async () => {
if (!scalingForm) return;
await setScalingPolicy({
service_id: serviceId,
min_replicas: Number(scalingForm.min) || 1,
max_replicas: Number(scalingForm.max) || 1,
target_cpu: Number(scalingForm.targetCpu) || 70,
target_memory: Number(scalingForm.targetMemory) || 80,
enabled: scalingForm.enabled,
});
},
onSuccess: () => { setScalingForm(null); invalidateScaling(); },
});
const deleteScalingMutation = useMutation({
mutationFn: () => deleteScalingPolicy(serviceId),
onSuccess: invalidateScaling,
});
const manualScaleMutation = useMutation({
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
onSuccess: () => {
invalidateScaling();
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services'] });
},
});
const triggerCronMutation = useMutation({
mutationFn: (id: string) => triggerCronJob(id),
onSuccess: () => {
window.setTimeout(() => {
invalidateCron();
if (historyCronId) queryClient.invalidateQueries({ queryKey: ['cron-executions', historyCronId] });
}, 2500);
},
});
const serviceLogsQuery = useQuery({
queryKey: ['service-logs', serviceId, logTail],
queryFn: () => listServiceLogs(serviceId, { tail: logTail }),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'logs',
});
const latestDeployment = useMemo(() => {
if (isDemoMode) {
return null;
}
const deployments = deploymentsQuery.data ?? [];
return deployments[0] ?? null;
}, [deploymentsQuery.data, isDemoMode]);
const logsDeployment =
(deploymentsQuery.data ?? []).find((d) => d.id === logDeploymentId) ?? latestDeployment;
const deploymentLogsQuery = useQuery({
queryKey: ['deployment-logs', logsDeployment?.id],
queryFn: () => getDeploymentLogs(logsDeployment!.id, { type: 'all' }),
enabled:
Boolean(logsDeployment?.id) &&
!isDemoMode &&
activeSection === 'logs',
});
const deployMutation = useMutation({
mutationFn: (trigger: 'manual' | 'restart') => createDeployment(serviceId, { trigger }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
},
});
const cancelDeploymentMutation = useMutation({
mutationFn: (deploymentId: string) => cancelDeployment(deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
},
});
const rollbackMutation = useMutation({
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
},
});
const deleteServiceMutation = useMutation({
mutationFn: () => deleteService(serviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`);
},
});
const projectsQuery = useQuery({
queryKey: ['projects'],
queryFn: () => listProjects({ limit: 100 }),
enabled: !isDemoMode && activeSection === 'settings',
});
const cloneMutation = useMutation({
mutationFn: (input: { name?: string; project_id?: string }) => cloneService(serviceId, input),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['project-services'] });
const targetProject = lifecycleForm?.cloneProject || projectId;
navigate(`/projects/${targetProject}/services/${data.service_id}`);
},
});
const moveMutation = useMutation({
mutationFn: (targetProjectId: string) => moveService(serviceId, targetProjectId),
onSuccess: (_data, targetProjectId) => {
queryClient.invalidateQueries({ queryKey: ['project-services'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(`/projects/${targetProjectId}/services/${serviceId}`);
},
});
const [lifecycleForm, setLifecycleForm] = useState<{
cloneName: string;
cloneProject: string;
moveTarget: string;
} | null>(null);
const invalidateService = () => {
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
};
const restartMutation = useMutation({
mutationFn: () => restartService(serviceId),
onSuccess: invalidateService,
});
const stopMutation = useMutation({
mutationFn: () => stopService(serviceId),
onSuccess: invalidateService,
});
const sleepMutation = useMutation({
mutationFn: () => sleepService(serviceId),
onSuccess: invalidateService,
});
const wakeMutation = useMutation({
mutationFn: () => wakeService(serviceId),
onSuccess: invalidateService,
});
const updateServiceMutation = useMutation({
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
onSuccess: () => {
setNetworkForm(null);
setSleepDraft(null);
setNodeDraft(null);
invalidateService();
},
});
const runtimeQuery = useQuery({
queryKey: ['service-runtime', serviceId],
queryFn: () => getServiceRuntime(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
refetchInterval: 10_000,
});
const [networkForm, setNetworkForm] = useState<{
port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string;
} | null>(null);
const [volumesForm, setVolumesForm] = useState<ServiceVolume[] | null>(null);
const [domainInput, setDomainInput] = useState('');
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
const [nodeDraft, setNodeDraft] = useState<string | null>(null);
const nodesQuery = useQuery({
queryKey: ['node-options'],
queryFn: listNodeOptions,
enabled: !isDemoMode,
staleTime: 30_000,
});
const [buildForm, setBuildForm] = useState<{
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
} | null>(null);
const domainsQuery = useQuery({
queryKey: ['service-domains', serviceId],
queryFn: () => listServiceDomains(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
});
const domainMutation = useMutation({
mutationFn: (fn: () => Promise<unknown>) => fn(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-domains', serviceId] });
invalidateService();
},
});
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
const sleepDraftCurrent = sleepDraft?.id === serviceId ? sleepDraft : null;
const sleepEnabled = sleepDraftCurrent?.enabled ?? Boolean(service?.sleepEnabled);
const sleepMinutes = sleepDraftCurrent?.minutes ?? String(service?.sleepIdleMinutes ?? 15);
const varRows: VariableDraft[] = useMemo(
() =>
varDrafts ??
(variablesQuery.data ?? []).map((v) => ({ key: v.key, value: v.value, isSecret: v.isSecret })),
[varDrafts, variablesQuery.data],
);
const varErrors = useMemo(() => validateVariableRows(varRows), [varRows]);
const varDirty = varDrafts !== null;
const updateVarRow = (index: number, patch: Partial<VariableDraft>) =>
setVarDrafts(varRows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
const removeVarRow = (index: number) => setVarDrafts(varRows.filter((_, i) => i !== index));
const applyBulkPaste = () => {
const parsed = parseDotenv(bulkText);
if (parsed.length === 0) return;
const merged = [...varRows];
for (const row of parsed) {
const existing = merged.findIndex((r) => r.key === row.key);
if (existing >= 0) {
merged[existing] = { ...merged[existing], value: row.value };
} else {
merged.push(row);
}
}
setVarDrafts(merged);
setBulkText('');
setBulkOpen(false);
};
const metricsQuery = useQuery({
queryKey: ['service-metrics', serviceId],
queryFn: () => getServiceMetrics(serviceId),
enabled: !isDemoMode && Boolean(serviceId),
refetchInterval: 5000,
});
// Accumulate live samples into a small history for the timeline charts.
const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[]; net: number[] }>({ cpu: [], memory: [], net: [] });
const lastNetSampleRef = useRef<{ rx: number; at: number } | null>(null);
useEffect(() => {
const sample = metricsQuery.data;
if (!sample || sample.status !== 'ok') {
return;
}
const memPercent = sample.memory_limit_bytes > 0
? (sample.memory_usage_bytes / sample.memory_limit_bytes) * 100
: 0;
const now = Date.now();
const previous = lastNetSampleRef.current;
const elapsedSec = previous ? Math.max(1, (now - previous.at) / 1000) : 0;
const rxRate = previous && elapsedSec > 0
? Math.max(0, (sample.network_rx_bytes - previous.rx) / elapsedSec)
: 0;
lastNetSampleRef.current = { rx: sample.network_rx_bytes, at: now };
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
setMetricHistory((prev) => ({
cpu: [...prev.cpu, Math.min(100, sample.cpu_percent)].slice(-24),
memory: [...prev.memory, Math.min(100, memPercent)].slice(-24),
net: [...prev.net, rxRate].slice(-24),
}));
}, [metricsQuery.data]);
const liveMetrics = isDemoMode ? null : metricsQuery.data ?? null;
const hasLiveTelemetry = Boolean(liveMetrics && liveMetrics.status === 'ok' && liveMetrics.instances.length > 0);
const runningInstances = liveMetrics?.instances.filter((instance) => instance.state === 'running').length ?? 0;
const memoryPercent = liveMetrics && liveMetrics.memory_limit_bytes > 0
? (liveMetrics.memory_usage_bytes / liveMetrics.memory_limit_bytes) * 100
: 0;
// Demo mode renders seeded values so the layout can be previewed without a backend.
const metricSet = useMemo(() => {
if (!service || !isDemoMode) {
return null;
}
return {
cpu: seededMetric(`${service.id}:cpu`, 12, 78),
memory: seededMetric(`${service.id}:mem`, 24, 91),
req: seededMetric(`${service.id}:req`, 120, 5100),
latency: seededMetric(`${service.id}:lat`, 17, 210),
};
}, [service, isDemoMode]);
const deploymentSummary = useMemo(() => {
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
const total = deployments.length;
const active = deployments.filter((deployment) =>
['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
).length;
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
return { total, active, failed };
}, [deploymentsQuery.data, isDemoMode]);
const canRollback = (status: string): boolean => status === 'deployed' || status === 'failed';
if (!isDemoMode && (projectQuery.isLoading || serviceQuery.isLoading)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-[var(--text-secondary)]">
<Loader2 size={20} className="animate-spin" />
<span>Loading service...</span>
</div>
</div>
);
}
if (!isDemoMode && serviceQuery.isError) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load service</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">{(serviceQuery.error as Error).message}</p>
<button
onClick={() => serviceQuery.refetch()}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Retry
</button>
</div>
</div>
);
}
if (!service || !project) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Service not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">This service may have been deleted.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen relative">
{/* Breadcrumb - self.html exact match */}
<div
className="flex items-center"
style={{
gap: '6px',
padding: '14px 24px 10px',
color: 'var(--text-tertiary)',
fontSize: '13px'
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
<button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
style={{ color: 'var(--text-tertiary)', textDecoration: 'none' }}
className="hover:text-[var(--text-secondary)] transition-colors"
>
Servers
</button>
<span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: 'var(--text-secondary)' }}>{service.name}</span>
</div>
{/* Project Header - self.html exact match */}
<div className="flex items-center" style={{ padding: '0 24px 18px' }}>
<div
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
style={{
width: '46px',
height: '46px',
background: `${serviceAccent(service)}1f`,
color: serviceAccent(service),
marginRight: '14px'
}}
>
<ServiceIcon service={service} size={22} />
</div>
<div>
<div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: 'var(--text-primary)' }}>{service.name}</span>
<span
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
>
{service.status === 'running' && <span className="live-dot" />}
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : service.status === 'sleeping' ? 'Sleeping' : 'Stopped'}
</span>
</div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
{(service.publicUrl || service.domain) && (
<a
href={service.publicUrl ?? `https://${service.domain}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
{service.publicUrl ?? `https://${service.domain}`}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
)}
<button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
className="flex items-center hover:text-[var(--text-secondary)] transition-colors"
style={{ color: 'var(--text-tertiary)', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
</div>
<div className="ml-auto flex" style={{ gap: '10px' }}>
{!isDemoMode && signedIn && (
<>
<button
onClick={() => stopMutation.mutate()}
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"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
{stopMutation.isPending ? 'STOPPING…' : 'STOP'}
</button>
<button
onClick={() => restartMutation.mutate()}
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"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
{restartMutation.isPending ? 'RESTARTING…' : 'RESTART'}
</button>
<button
onClick={() => deployMutation.mutate('manual')}
disabled={deployMutation.isPending}
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
</svg>
{deployMutation.isPending ? 'DEPLOYING…' : 'DEPLOY'}
</button>
</>
)}
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="w-full px-8 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data for preview</span>
</div>
</div>
</div>
)}
{/* Metrics Overview */}
<div className="w-full px-8 py-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<EnhancedMetricCard
title="CPU Usage"
icon={<Cpu size={18} />}
value={isDemoMode ? `${metricSet?.cpu ?? 0}%` : hasLiveTelemetry ? `${liveMetrics!.cpu_percent.toFixed(1)}%` : '—'}
status={metricStatus(isDemoMode ? metricSet?.cpu ?? 0 : liveMetrics?.cpu_percent ?? 0)}
statusText={isDemoMode || hasLiveTelemetry ? statusLabel[metricStatus(isDemoMode ? metricSet?.cpu ?? 0 : liveMetrics?.cpu_percent ?? 0)] : ''}
subtitle={isDemoMode ? 'Current utilization' : hasLiveTelemetry ? `${runningInstances} running container${runningInstances === 1 ? '' : 's'}` : 'No running containers'}
chart={
<LineAreaChart
data={isDemoMode
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:cpu:${i}`, 8, 80))
: metricHistory.cpu.length > 0 ? metricHistory.cpu : [0]}
color="var(--accent-primary)"
height={72}
/>
}
/>
<EnhancedMetricCard
title="Memory"
icon={<MemoryStick size={18} />}
value={isDemoMode ? `${metricSet?.memory ?? 0}%` : hasLiveTelemetry ? `${memoryPercent.toFixed(0)}%` : '—'}
status={metricStatus(isDemoMode ? metricSet?.memory ?? 0 : memoryPercent)}
statusText={isDemoMode || hasLiveTelemetry ? statusLabel[metricStatus(isDemoMode ? metricSet?.memory ?? 0 : memoryPercent)] : ''}
subtitle={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${formatBytes(liveMetrics!.memory_usage_bytes)} used` : 'No running containers'}
chart={
<div className="relative mx-auto" style={{ width: 150 }}>
<DonutChart percentage={isDemoMode ? metricSet?.memory ?? 0 : memoryPercent} color="var(--warning)" size={150} thickness={14} />
<div className="absolute inset-x-0 bottom-0 text-center">
<div className="text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">Used</div>
<div className="text-sm font-bold text-[var(--text-primary)]">
{isDemoMode
? `${((8 * (metricSet?.memory ?? 0)) / 100).toFixed(1)} GB / 8 GB`
: hasLiveTelemetry
? `${formatBytes(liveMetrics!.memory_usage_bytes)} / ${formatBytes(liveMetrics!.memory_limit_bytes)}`
: '—'}
</div>
</div>
</div>
}
/>
<EnhancedMetricCard
title="Network"
icon={<Zap size={18} />}
value={isDemoMode ? `${metricSet?.req ?? 0}` : hasLiveTelemetry ? formatBytes(liveMetrics!.network_rx_bytes) : '—'}
status="good"
statusText={isDemoMode || hasLiveTelemetry ? 'RX' : ''}
subtitle={isDemoMode ? 'Requests, last 60 minutes' : hasLiveTelemetry ? `TX ${formatBytes(liveMetrics!.network_tx_bytes)}` : 'No traffic recorded'}
chart={
<LineAreaChart
data={isDemoMode
? Array.from({ length: 24 }, (_, i) => seededMetric(`${service.id}:net:${i}`, 15, 70))
: metricHistory.net.length > 0 ? metricHistory.net : [0]}
color="var(--info)"
height={72}
/>
}
/>
<EnhancedMetricCard
title="Instances"
icon={<Timer size={18} />}
value={isDemoMode ? `${metricSet?.latency ?? 0}ms` : hasLiveTelemetry ? `${runningInstances}` : '0'}
statusText={hasLiveTelemetry || isDemoMode ? 'Running' : ''}
status="good"
subtitle={isDemoMode ? 'P95 latency estimate' : hasLiveTelemetry ? `${liveMetrics!.instances.length} container${liveMetrics!.instances.length === 1 ? '' : 's'} discovered` : 'Deploy to create containers'}
/>
</div>
{/* Deployment Summary */}
{!isDemoMode && (
<div className="mt-4 flex items-center gap-6 text-sm">
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
<span className="w-2 h-2 rounded-full bg-[var(--accent-primary)]" />
<span>{deploymentSummary.total} deployments</span>
</div>
{deploymentSummary.active > 0 && (
<div className="flex items-center gap-2 text-[var(--warning)]">
<span className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<span>{deploymentSummary.active} active</span>
</div>
)}
{deploymentSummary.failed > 0 && (
<div className="flex items-center gap-2 text-[var(--error)]">
<span className="w-2 h-2 rounded-full bg-[var(--error)]" />
<span>{deploymentSummary.failed} failed</span>
</div>
)}
{latestDeployment?.createdAt && (
<div className="flex items-center gap-1.5 text-[var(--text-tertiary)]">
<Clock size={12} />
<span>Latest {formatRelative(latestDeployment.createdAt)}</span>
</div>
)}
</div>
)}
</div>
{/* Tabs - self.html exact match */}
<div
className="flex"
style={{
borderBottom: '1px solid var(--border-subtle)',
marginBottom: '18px',
padding: '0 24px'
}}
>
{sectionItems.filter((item) => signedIn || !item.manageOnly).map((item) => {
const active = activeSection === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setActiveSection(item.key)}
className={`tab ${active ? 'active' : ''}`}
>
<Icon size={14} />
{item.label}
</button>
);
})}
</div>
{/* Content - self.html exact match: padding 0 24px 28px */}
<div style={{ padding: '0 24px 28px' }}>
{activeSection === 'metrics' && (
<div className="space-y-6">
{/* Metrics Grid with enhanced cards */}
{!isDemoMode && !hasLiveTelemetry && (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Activity size={22} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-base font-medium text-[var(--text-primary)]">No telemetry yet</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{liveMetrics?.status === 'docker_unavailable'
? 'Docker is not reachable on this host, so container metrics cannot be collected.'
: 'Metrics appear once this service has running containers. Deploy it to start collecting.'}
</p>
</div>
)}
{(isDemoMode || hasLiveTelemetry) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="panel p-5 group hover:border-[var(--accent-primary)]/30 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="card-icon">
<Cpu size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">CPU Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
</p>
</div>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{isDemoMode
? Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: 'var(--accent-primary)' }}
title={`${value}%`}
/>
);
})
: metricHistory.cpu.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: 'var(--accent-primary)' }}
title={`${value.toFixed(1)}%`}
/>
))}
{!isDemoMode && metricHistory.cpu.length === 0 && (
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
)}
</div>
</div>
<div className="panel p-5 group hover:border-[var(--success)]/30 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="card-icon">
<MemoryStick size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Memory Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
</p>
</div>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{isDemoMode
? Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80 cursor-pointer"
style={{ height: `${value}%`, background: 'var(--success)' }}
title={`${value}%`}
/>
);
})
: metricHistory.memory.map((value, i) => (
<div
key={i}
className="flex-1 rounded-[var(--radius-sm)] transition-all hover:opacity-80"
style={{ height: `${Math.max(3, value)}%`, background: 'var(--success)' }}
title={`${value.toFixed(1)}%`}
/>
))}
{!isDemoMode && metricHistory.memory.length === 0 && (
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
)}
</div>
</div>
</div>
)}
{/* Instance list — real container states */}
{!isDemoMode && hasLiveTelemetry && (
<div className="panel p-5">
<div className="flex items-center gap-3 mb-4">
<div className="card-icon">
<Layers size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Instances</p>
<p className="text-xs text-[var(--text-tertiary)]">Containers backing this service</p>
</div>
</div>
<div className="space-y-2">
{liveMetrics!.instances.map((instance) => (
<div
key={instance.container_id}
className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"
>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${instance.state === 'running' ? 'bg-[var(--success)]' : 'bg-[var(--text-muted)]'}`} />
<span className="mono text-xs text-[var(--text-primary)] truncate">{instance.name}</span>
</div>
<div className="flex items-center gap-4 text-xs text-[var(--text-tertiary)] flex-shrink-0">
<span className="mono">{instance.cpu_percent.toFixed(1)}% CPU</span>
<span className="mono">{formatBytes(instance.memory_usage_bytes)}</span>
<span>{instance.started_at ? `up ${formatRelative(instance.started_at).replace(' ago', '')}` : instance.state}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Domain & Networking Panel */}
{service.status === 'running' && (
<div className="panel p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Zap size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Networking</p>
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
</div>
{runtimeQuery.data?.health === 'healthy' && (
<span className="ml-auto v-st v-st-ok">HEALTHY</span>
)}
{runtimeQuery.data?.health === 'unhealthy' && (
<span className="ml-auto v-st v-st-fail">UNHEALTHY</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{service.domain && (
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">Domain</span>
</div>
<a
href={`https://${service.domain}`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{service.domain}
</a>
</div>
)}
{(runtimeQuery.data?.urls ?? []).map((url) => (
<div key={url} className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">HTTP</span>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{url.replace(/^https?:\/\//, '')}
</a>
</div>
))}
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" />
<span className="text-xs text-[var(--text-secondary)]">Internal</span>
</div>
<span className="mono text-xs text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
</div>
</div>
{!service.domain && (runtimeQuery.data?.urls ?? []).length === 0 && (
<p className="mt-3 text-xs text-[var(--text-tertiary)]">
No public endpoint. Publish a port or set a domain in Settings → Networking to expose this service.
</p>
)}
</div>
)}
</div>
)}
{activeSection === 'logs' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Service Logs</h2>
<p className="text-sm text-[var(--text-secondary)]">Container stdout/stderr output</p>
</div>
</div>
{!isDemoMode && (
<div className="flex items-center gap-3">
<select
value={logTail}
onChange={(e) => setLogTail(e.target.value)}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm"
>
<option value="50">50 lines</option>
<option value="100">100 lines</option>
<option value="250">250 lines</option>
</select>
<button
onClick={() => {
serviceLogsQuery.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"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
)}
</div>
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] max-h-[500px] overflow-auto">
{isDemoMode ? (
<div className="space-y-1">
{Array.from({ length: 15 }).map((_, i) => (
<p key={i}>
<span className="text-[var(--text-muted)]">
[{service.updatedAt ? new Date(new Date(service.updatedAt).getTime() - i * 45000).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{service.name}</span>
{' '}
<span className="text-[var(--text-tertiary)]">{i % 3 === 0 ? 'health_check=ok' : 'request=200'}</span>
</p>
))}
</div>
) : serviceLogsQuery.isLoading ? (
<p className="text-[var(--text-muted)]">Loading logs...</p>
) : serviceLogsQuery.isError ? (
<p className="text-[var(--error)]">Failed to load logs.</p>
) : (serviceLogsQuery.data?.length ?? 0) === 0 ? (
<p className="text-[var(--text-muted)]">No logs available.</p>
) : (
<div className="space-y-1">
{serviceLogsQuery.data!.map((entry, i) => (
<p key={`${entry.timestamp}-${i}`}>
<span className="text-[var(--text-muted)]">
[{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{entry.stream}</span>
{' '}
{entry.message}
</p>
))}
</div>
)}
</div>
{!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)]">
{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">{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...'
: deploymentLogsQuery.isError
? 'Failed to load.'
: deploymentLogsQuery.data?.buildLog || deploymentLogsQuery.data?.runtimeLog || '(no output)'}
</pre>
</div>
)}
</div>
)}
{activeSection === 'config' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Sliders size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Configuration</h2>
<p className="text-sm text-[var(--text-secondary)]">Runtime and deployment settings</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Environment</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{service.environment ?? 'production'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Image</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.image ?? 'not set'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Command</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.command ?? 'default'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Git Branch</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)]">{service.gitBranch ?? 'not configured'}</p>
</div>
</div>
</div>
)}
{activeSection === 'variables' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<KeyRound size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Environment Variables</h2>
<p className="text-sm text-[var(--text-secondary)]">
Runtime values injected at deploy. Reference another service's variable with{' '}
<code className="mono text-[var(--accent-primary)]">{'${{service.KEY}}'}</code> or a project
shared variable with{' '}
<code className="mono text-[var(--accent-primary)]">{'${{shared.KEY}}'}</code>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setBulkOpen((open) => !open)}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<ClipboardPaste size={12} />
Paste .env
</button>
<button
onClick={() => setVarDrafts([...varRows, { key: '', value: '', isSecret: false }])}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} />
Add variable
</button>
<button
onClick={() => saveVariablesMutation.mutate(varRows)}
disabled={!varDirty || Object.keys(varErrors).length > 0 || saveVariablesMutation.isPending}
className="flex items-center gap-2 px-4 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] shadow transition-all disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'var(--accent-primary)' }}
>
{saveVariablesMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
Save
</button>
</div>
</div>
{envCheckQuery.data && !envCheckQuery.data.ok && (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--warning)]/40 bg-[var(--warning-soft)] p-4">
<p className="text-xs font-semibold text-[var(--warning)] mb-2">Environment issues detected</p>
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
{(envCheckQuery.data.unresolved ?? []).map((u) => (
<li key={u} className="mono">Unresolved reference: {u}</li>
))}
{(envCheckQuery.data.empty ?? []).map((k) => (
<li key={k} className="mono">Empty value: {k}</li>
))}
{(envCheckQuery.data.unreadable ?? []).map((k) => (
<li key={k} className="mono">Cannot decrypt secret (key rotated?): {k}</li>
))}
</ul>
</div>
)}
{bulkOpen && (
<div className="mb-4 panel-soft p-4">
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Paste .env contents
</label>
<textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
rows={6}
placeholder={'DATABASE_URL=postgres://...\nAPI_KEY=secret\n# comments are ignored'}
className="w-full px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<div className="mt-3 flex justify-end gap-2">
<button
onClick={() => { setBulkOpen(false); setBulkText(''); }}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Cancel
</button>
<button
onClick={applyBulkPaste}
className="px-3 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all"
style={{ background: 'var(--accent-primary)' }}
>
Import {parseDotenv(bulkText).length > 0 ? `${parseDotenv(bulkText).length} variables` : ''}
</button>
</div>
</div>
)}
{variablesQuery.isLoading ? (
<div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
<Loader2 size={20} className="animate-spin" />
</div>
) : varRows.length === 0 ? (
<div className="panel-soft p-8 text-center">
<p className="text-sm text-[var(--text-secondary)]">No variables configured.</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">Add rows or paste a .env file to get started.</p>
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 px-1">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Key</span>
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Value</span>
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Secret</span>
<span />
</div>
{varRows.map((row, i) => (
<div key={i}>
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
<input
value={row.key}
onChange={(e) => updateVarRow(i, { key: e.target.value })}
placeholder="KEY"
className={`h-10 px-3 rounded-[var(--radius-md)] border bg-[var(--surface-muted)] mono text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:ring-1 transition-all ${
varErrors[i]
? 'border-[var(--error)] focus:border-[var(--error)] focus:ring-[var(--error)]'
: 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)] focus:ring-[var(--accent-primary)]'
}`}
/>
<input
value={row.value}
onChange={(e) => updateVarRow(i, { value: e.target.value })}
type={row.isSecret ? 'password' : 'text'}
placeholder="value"
autoComplete="off"
className="h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<button
type="button"
title={row.isSecret ? 'Secret — value masked on save' : 'Mark as secret'}
onClick={() => updateVarRow(i, { isSecret: !row.isSecret })}
className={`w-10 h-10 rounded-[var(--radius-md)] border flex items-center justify-center transition-colors ${
row.isSecret
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:border-[var(--border-default)]'
}`}
>
<KeyRound size={14} />
</button>
<button
type="button"
onClick={() => removeVarRow(i)}
className="w-10 h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] flex items-center justify-center text-[var(--text-muted)] hover:border-[var(--error)] hover:text-[var(--error)] transition-colors"
>
<Trash2 size={14} />
</button>
</div>
{varErrors[i] && (
<p className="mt-1 px-1 text-xs text-[var(--error)]">{varErrors[i]}</p>
)}
</div>
))}
</div>
)}
<p className="mt-4 text-xs text-[var(--text-muted)]">
Secret values are stored encrypted and masked as ******** in responses. Leaving a masked
value unchanged keeps the stored secret; editing replaces it. Saving replaces the full set.
</p>
{saveVariablesMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">
{saveVariablesMutation.error instanceof Error
? saveVariablesMutation.error.message
: 'Failed to save variables'}
</p>
)}
</div>
)}
{activeSection === 'cron' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Cron Jobs</h2>
<p className="text-sm text-[var(--text-secondary)]">Scheduled commands executed inside the service container</p>
</div>
</div>
{signedIn ? (
<button
onClick={() => { setEditingCronId(null); setCronForm({ name: '', schedule: '', command: '', timezone: 'UTC', enabled: true, retention: 30 }); }}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} />
New job
</button>
) : null}
</div>
{cronForm && (
<div className="mb-4 panel-soft p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
value={cronForm.name}
onChange={(e) => setCronForm({ ...cronForm, name: e.target.value })}
placeholder="Name (e.g. Nightly backup)"
className="px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<input
value={cronForm.schedule}
onChange={(e) => setCronForm({ ...cronForm, schedule: e.target.value })}
placeholder="Schedule (e.g. 0 3 * * * or @daily)"
className="px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
</div>
<input
value={cronForm.command}
onChange={(e) => setCronForm({ ...cronForm, command: e.target.value })}
placeholder="Command (runs via sh -c inside the container)"
className="w-full px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<div className="flex flex-wrap items-center gap-3">
<input
value={cronForm.timezone}
onChange={(e) => setCronForm({ ...cronForm, timezone: e.target.value })}
placeholder="Timezone"
className="w-32 px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<input type="number" min={1} value={cronForm.retention}
onChange={(e) => setCronForm({ ...cronForm, retention: Number(e.target.value) || 30 })}
className="w-16 px-2 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)]" />
runs kept
</label>
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<input type="checkbox" checked={cronForm.enabled}
onChange={(e) => setCronForm({ ...cronForm, enabled: e.target.checked })} />
Enabled
</label>
<div className="ml-auto flex gap-2">
<button
onClick={() => { setCronForm(null); setEditingCronId(null); }}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Cancel
</button>
<button
onClick={() => saveCronMutation.mutate()}
disabled={!cronForm.name.trim() || !cronForm.schedule.trim() || !cronForm.command.trim() || saveCronMutation.isPending}
className="flex items-center gap-2 px-4 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
{saveCronMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
{editingCronId ? 'Save job' : 'Create job'}
</button>
</div>
</div>
{saveCronMutation.isError && (
<p className="text-xs text-[var(--error)]">
{saveCronMutation.error instanceof Error ? saveCronMutation.error.message : 'Failed to save job'}
</p>
)}
</div>
)}
{cronJobsQuery.isLoading ? (
<div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
<Loader2 size={20} className="animate-spin" />
</div>
) : (isDemoMode ? getDemoCronJobsByService(serviceId) : cronJobsQuery.data ?? []).length === 0 ? (
<div className="panel-soft p-8 text-center">
<p className="text-sm text-[var(--text-secondary)]">No cron jobs configured.</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">Create a job to run commands on a schedule inside this service's container.</p>
</div>
) : (
<div className="space-y-2">
{(isDemoMode ? getDemoCronJobsByService(serviceId) : cronJobsQuery.data ?? []).map((job) => (
<div key={job.id} className="panel-soft p-3">
<div className="flex items-center gap-3">
<button
onClick={() => signedIn && toggleCronMutation.mutate(job)}
title={job.enabled ? 'Disable' : 'Enable'}
disabled={!signedIn}
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'} disabled:opacity-60 disabled:cursor-not-allowed`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${job.enabled ? 'left-3.5' : 'left-0.5'}`} />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)] truncate">{job.name}</span>
<span className="mono text-xs text-[var(--text-muted)]">{job.schedule}</span>
</div>
<p className="mono text-xs text-[var(--text-tertiary)] truncate">{job.command}</p>
<p className="text-xs text-[var(--text-muted)]">
{job.next_run_at ? `next ${formatRelative(job.next_run_at)}` : 'not scheduled'}
{job.last_status ? ` · last ${job.last_status}` : ''}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{signedIn ? (
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
disabled={triggerCronMutation.isPending}
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors disabled:opacity-50">
<Play size={13} />
</button>
) : null}
<button onClick={() => setHistoryCronId(historyCronId === job.id ? null : (job.id as string))} title="History"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
<ChevronDown size={13} className={historyCronId === job.id ? 'rotate-180 transition-transform' : 'transition-transform'} />
</button>
{signedIn ? (
<>
<button
onClick={() => { setEditingCronId(job.id as string); setCronForm({ name: job.name ?? '', schedule: job.schedule ?? '', command: job.command ?? '', timezone: job.timezone ?? 'UTC', enabled: job.enabled ?? true, retention: job.retention ?? 30 }); }}
title="Edit"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
<Sliders size={13} />
</button>
<button
onClick={() => { if (window.confirm(`Delete cron job "${job.name}"?`)) deleteCronMutation.mutate(job.id as string); }}
title="Delete"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors">
<Trash2 size={13} />
</button>
</>
) : null}
</div>
</div>
{historyCronId === job.id && (
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
{cronExecutionsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading history…</p>
) : (cronExecutionsQuery.data ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No executions yet.</p>
) : (
<div className="space-y-2">
{(cronExecutionsQuery.data ?? []).slice(0, 10).map((ex) => (
<div key={ex.id} className="text-xs">
<div className="flex items-center gap-2">
<span className={`inline-block w-1.5 h-1.5 rounded-full ${ex.status === 'success' ? 'bg-[var(--success)]' : ex.status === 'running' ? 'bg-[var(--warning)]' : 'bg-[var(--error)]'}`} />
<span className="text-[var(--text-secondary)]">{formatRelative(ex.started_at)}</span>
<span className="text-[var(--text-muted)]">{ex.status}</span>
</div>
{(ex.output || ex.error) && (
<pre className="mono mt-1 ml-3.5 whitespace-pre-wrap break-all text-[var(--text-tertiary)]">{ex.output || ex.error}</pre>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{activeSection === 'previews' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<GitPullRequest size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Preview Environments</h2>
<p className="text-sm text-[var(--text-secondary)]">Ephemeral per-branch deployments of this service</p>
</div>
</div>
{signedIn ? (
<button
onClick={() => setPreviewForm({ branch: '', prNumber: '', ttlHours: '72' })}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} /> New preview
</button>
) : null}
</div>
{previewForm && (
<div className="mb-6 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-4 space-y-3">
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
Branch
<input
value={previewForm.branch}
onChange={(e) => setPreviewForm({ ...previewForm, branch: e.target.value })}
placeholder="feature/my-branch"
className="w-52 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
PR number (optional)
<input
value={previewForm.prNumber}
onChange={(e) => setPreviewForm({ ...previewForm, prNumber: e.target.value })}
placeholder="123"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-[var(--text-muted)]">
TTL hours
<input
value={previewForm.ttlHours}
onChange={(e) => setPreviewForm({ ...previewForm, ttlHours: e.target.value })}
placeholder="72"
className="w-24 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] px-2 py-1.5 text-xs text-[var(--text-primary)]"
/>
</label>
<button
onClick={() => createPreviewMutation.mutate()}
disabled={!previewForm.branch.trim() || createPreviewMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-40"
>
{createPreviewMutation.isPending ? <Loader2 size={11} className="animate-spin" /> : <Plus size={11} />} Create
</button>
<button
onClick={() => setPreviewForm(null)}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)]"
>
Cancel
</button>
</div>
{createPreviewMutation.isError && (
<p className="text-xs text-[var(--error)]">
{createPreviewMutation.error instanceof Error ? createPreviewMutation.error.message : 'Create failed'}
</p>
)}
</div>
)}
{previewsQuery.isLoading ? (
<p className="text-sm text-[var(--text-muted)]">Loading previews…</p>
) : previews.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]">No preview environments for this service.</p>
) : (
<div className="space-y-2">
{previews.map((env) => (
<div key={env.id} className="flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{env.environment}</span>
<StatusBadge status={env.status ?? 'stopped'} />
</div>
<p className="mt-0.5 text-xs text-[var(--text-muted)]">
{env.branch_name}
{env.pr_number ? ` · PR #${env.pr_number}` : ''}
{env.expires_at ? ` · expires ${formatRelative(env.expires_at)}` : ''}
</p>
</div>
{env.url && env.status === 'running' && (
<a
href={env.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline"
>
<ExternalLink size={11} /> Open
</a>
)}
{env.status === 'running' && signedIn && (
<button
onClick={() => { if (window.confirm('Promote this preview to production?')) promotePreviewMutation.mutate(env.id ?? ''); }}
disabled={promotePreviewMutation.isPending}
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
>
Promote
</button>
)}
{signedIn ? (
<button
onClick={() => { if (window.confirm(`Delete preview ${env.environment}?`)) deletePreviewMutation.mutate(env.id ?? ''); }}
disabled={deletePreviewMutation.isPending}
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--error)] hover:border-[var(--error)] disabled:opacity-40"
>
Delete
</button>
) : null}
</div>
))}
</div>
)}
{(deletePreviewMutation.isError || promotePreviewMutation.isError) && (
<p className="mt-3 text-xs text-[var(--error)]">
{((deletePreviewMutation.error ?? promotePreviewMutation.error) as Error)?.message ?? 'Operation failed'}
</p>
)}
</div>
)}
{activeSection === 'scaling' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Layers size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Scaling</h2>
<p className="text-sm text-[var(--text-secondary)]">Replica policy and manual scaling</p>
</div>
</div>
</div>
{scalingPolicyQuery.isLoading ? (
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] 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 ??
runtimeQuery.data?.containers.filter((c) => c.state === 'running').length ??
'—'}
<span className="text-sm font-normal text-[var(--text-secondary)]">
{' '}/ {scalingStateQuery.data?.DesiredReplicas ?? runtimeQuery.data?.desired ?? service?.replicas ?? '—'} desired
</span>
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Policy</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingPolicyQuery.data ? `${scalingPolicyQuery.data.min_replicas}–${scalingPolicyQuery.data.max_replicas}` : 'None'}
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Autoscaling</p>
<p className="text-xl font-semibold text-[var(--text-primary)]">
{scalingPolicyQuery.data?.enabled ? 'On' : 'Off'}
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">Last action</p>
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
{scalingStateQuery.data?.LastScaleDirection ?? '—'}
</p>
</div>
</div>
{scalingForm === null ? (
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Manual replicas</label>
<input
type="number"
min={1}
max={20}
value={scaleReplicas}
onChange={(e) => setScaleReplicas(e.target.value)}
className="w-28 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
placeholder="e.g. 3"
/>
</div>
{isAdmin ? (
<>
<button
type="button"
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-on)] text-sm font-medium disabled:opacity-50"
>
{manualScaleMutation.isPending ? 'Scaling…' : 'Scale'}
</button>
<button
type="button"
onClick={() => setScalingForm({
min: String(scalingPolicyQuery.data?.min_replicas ?? 1),
max: String(scalingPolicyQuery.data?.max_replicas ?? 3),
targetCpu: String(scalingPolicyQuery.data?.target_cpu ?? 70),
targetMemory: String(scalingPolicyQuery.data?.target_memory ?? 80),
enabled: scalingPolicyQuery.data?.enabled ?? true,
})}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
{scalingPolicyQuery.data ? 'Edit policy' : 'Create policy'}
</button>
{scalingPolicyQuery.data?.enabled && (
<button
type="button"
disabled={deleteScalingMutation.isPending}
onClick={() => { if (window.confirm('Disable autoscaling for this service?')) deleteScalingMutation.mutate(); }}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error)] text-sm text-[var(--error)] disabled:opacity-50"
>
Disable policy
</button>
)}
</>
) : (
<span className="self-center text-xs text-[var(--text-tertiary)]">Scaling policy changes are admin-only.</span>
)}
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{([
['Min replicas', 'min'],
['Max replicas', 'max'],
['Target CPU %', 'targetCpu'],
['Target memory %', 'targetMemory'],
] as const).map(([label, field]) => (
<div key={field}>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">{label}</label>
<input
type="number"
min={field === 'min' || field === 'max' ? 1 : 0}
value={scalingForm[field]}
onChange={(e) => setScalingForm({ ...scalingForm, [field]: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/>
</div>
))}
</div>
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
checked={scalingForm.enabled}
onChange={(e) => setScalingForm({ ...scalingForm, enabled: e.target.checked })}
/>
Enable autoscaling
</label>
<div className="flex items-center gap-3">
<button
type="button"
disabled={saveScalingMutation.isPending}
onClick={() => saveScalingMutation.mutate()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{saveScalingMutation.isPending ? 'Saving…' : 'Save policy'}
</button>
<button
type="button"
onClick={() => setScalingForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
</div>
</div>
)}
{(saveScalingMutation.isError || deleteScalingMutation.isError || manualScaleMutation.isError || scalingPolicyQuery.isError || scalingStateQuery.isError) && (
<p className="mt-3 text-xs text-[var(--error)]">
{((saveScalingMutation.error ?? deleteScalingMutation.error ?? manualScaleMutation.error ?? scalingPolicyQuery.error ?? scalingStateQuery.error) as Error)?.message ?? 'Operation failed'}
</p>
)}
{!scalingStateQuery.data && !scalingStateQuery.isLoading && (
<p className="mt-3 text-xs text-[var(--text-tertiary)]">
No scaling state yet — create a policy or scale manually to register this service with the autoscaler.
</p>
)}
</>
)}
</div>
)}
{activeSection === 'console' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Terminal size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Console</h2>
<p className="text-sm text-[var(--text-secondary)]">Interactive shell inside the service container</p>
</div>
</div>
<ServiceTerminal serviceId={serviceId} />
</div>
)}
{activeSection === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<p className="text-sm text-[var(--text-secondary)]">Service metadata and history</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Service ID</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.id}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(service.createdAt)}</p>
</div>
<div className="panel-soft p-4 md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Last Update</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatRelative(service.updatedAt)}</p>
</div>
</div>
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Networking</h3>
{networkForm === null && (
<button
type="button"
onClick={() =>
setNetworkForm({
port: service.port ? String(service.port) : '',
domain: service.domain ?? '',
healthcheckPath: service.healthcheckPath ?? '',
restartPolicy: service.restartPolicy ?? 'unless-stopped',
replicas: String(service.replicas ?? 1),
})
}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{runtimeQuery.data && (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 p-3">
<div className="flex items-center gap-4 text-xs">
<span className="text-[var(--text-tertiary)]">
Live: <span className="text-[var(--text-primary)] font-medium">{runtimeQuery.data.containers.filter((c) => c.state === 'running').length}/{runtimeQuery.data.desired}</span> replicas
</span>
<span className="text-[var(--text-tertiary)]">
Internal: <span className="mono text-[var(--text-primary)]">{service.name}{service.port ? `:${service.port}` : ''}</span>
</span>
{runtimeQuery.data.urls.map((url) => (
<a key={url} href={url} target="_blank" rel="noopener noreferrer" className="mono text-[var(--accent-primary)] hover:underline">
{url}
</a>
))}
</div>
</div>
)}
{networkForm === null ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Port</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.port || '—'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Domain</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)] truncate">{service.domain || '—'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Replicas</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.replicas ?? 1}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Restart</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.restartPolicy || 'unless-stopped'}</p>
</div>
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Container port</label>
<input
type="number"
min={0}
max={65535}
value={networkForm.port}
onChange={(e) => setNetworkForm({ ...networkForm, port: e.target.value })}
placeholder="e.g. 3000"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">Published on the host; routed via Traefik when a domain is set</p>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Domain</label>
<input
value={networkForm.domain}
onChange={(e) => setNetworkForm({ ...networkForm, domain: e.target.value })}
placeholder="app.example.com"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Health check path</label>
<input
value={networkForm.healthcheckPath}
onChange={(e) => setNetworkForm({ ...networkForm, healthcheckPath: e.target.value })}
placeholder="/health"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Replicas</label>
<input
type="number"
min={1}
max={20}
value={networkForm.replicas}
onChange={(e) => setNetworkForm({ ...networkForm, replicas: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Restart policy</label>
<select
value={networkForm.restartPolicy}
onChange={(e) => setNetworkForm({ ...networkForm, restartPolicy: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="unless-stopped">unless-stopped</option>
<option value="always">always</option>
<option value="on-failure">on-failure</option>
<option value="no">no</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() =>
updateServiceMutation.mutate({
port: Number(networkForm.port) || 0,
domain: networkForm.domain,
healthcheck_path: networkForm.healthcheckPath,
restart_policy: networkForm.restartPolicy,
replicas: Math.max(1, Math.min(20, Number(networkForm.replicas) || 1)),
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setNetworkForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Takes effect on next deploy or redeploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Volumes</h3>
{volumesForm === null && (
<button
type="button"
onClick={() => setVolumesForm([...(service.volumes ?? [])])}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{volumesForm === null ? (
(service.volumes ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">No volume mounts configured.</p>
) : (
<div className="space-y-2">
{(service.volumes ?? []).map((v, i) => (
<div key={i} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
<span className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-tertiary)] uppercase tracking-wide">{v.type || 'volume'}</span>
<span className="mono text-[var(--text-primary)]">{v.source}</span>
<span className="text-[var(--text-tertiary)]">→</span>
<span className="mono text-[var(--text-primary)]">{v.target}</span>
{v.read_only && <span className="text-[var(--text-tertiary)]">(read-only)</span>}
</div>
))}
</div>
)
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-3">
{volumesForm.map((v, i) => (
<div key={i} className="grid grid-cols-[80px_1fr_1fr_auto_auto] items-center gap-2">
<select
value={v.type || 'volume'}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, type: e.target.value as 'volume' | 'bind' };
setVolumesForm(next);
}}
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs text-[var(--text-primary)]"
>
<option value="volume">volume</option>
<option value="bind">bind</option>
</select>
<input
value={v.source}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, source: e.target.value };
setVolumesForm(next);
}}
placeholder={v.type === 'bind' ? '/host/path' : 'volume-name'}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
/>
<input
value={v.target}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, target: e.target.value };
setVolumesForm(next);
}}
placeholder="/container/path"
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-xs mono text-[var(--text-primary)]"
/>
<label className="flex items-center gap-1 text-[10px] text-[var(--text-tertiary)]">
<input
type="checkbox"
checked={Boolean(v.read_only)}
onChange={(e) => {
const next = [...volumesForm];
next[i] = { ...v, read_only: e.target.checked };
setVolumesForm(next);
}}
/>
ro
</label>
<button
type="button"
onClick={() => setVolumesForm(volumesForm.filter((_, j) => j !== i))}
className="px-2 py-1 text-xs text-[var(--error)]"
>
<X size={12} />
</button>
</div>
))}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setVolumesForm([...volumesForm, { type: 'volume', source: '', target: '', read_only: false }])}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Add mount
</button>
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() =>
updateServiceMutation.mutate(
{
volumes: volumesForm
.filter((v) => v.source.trim() && v.target.startsWith('/'))
.map((v) => ({ type: v.type ?? 'volume', source: v.source, target: v.target, read_only: v.read_only ?? false })),
},
{ onSuccess: () => setVolumesForm(null) },
)
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setVolumesForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Replaces all mounts; applies on next deploy or redeploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Domains</h3>
<button
type="button"
disabled={domainMutation.isPending}
onClick={async () => {
const results = await checkServiceDomains(serviceId);
setDomainChecks(Object.fromEntries(results.map((r) => [r.domain, r])));
}}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Check DNS
</button>
</div>
<div className="space-y-2 mb-3">
{(domainsQuery.data ?? []).length === 0 && !domainsQuery.isLoading && (
<p className="text-xs text-[var(--text-tertiary)]">No domains attached.</p>
)}
{(domainsQuery.data ?? []).map((d: ServiceDomain) => {
const check = domainChecks?.[d.domain];
return (
<div key={d.id} className="flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 py-2 text-xs">
<span className="mono text-[var(--text-primary)]">{d.domain}</span>
{d.is_default && <span className="px-1.5 py-0.5 rounded bg-[var(--accent-primary)]/15 text-[var(--accent-primary)]">default</span>}
{check && (
<span className={`px-1.5 py-0.5 rounded ${check.status === 'ok' ? 'bg-[var(--success-soft)] text-[var(--success)]' : check.status === 'pending' ? 'bg-[var(--warning-soft)] text-[var(--warning)]' : 'bg-[var(--error-soft)] text-[var(--error)]'}`}>
{check.status}
</span>
)}
<span className="flex-1" />
{!d.is_default && (
<button
type="button"
onClick={() => domainMutation.mutate(() => setDefaultServiceDomain(serviceId, d.id))}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
set default
</button>
)}
<button
type="button"
onClick={() => domainMutation.mutate(() => removeServiceDomain(serviceId, d.id))}
className="text-[var(--error)]"
>
<X size={12} />
</button>
</div>
);
})}
</div>
<div className="flex items-center gap-2">
<input
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="app.example.com"
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
<button
type="button"
disabled={domainMutation.isPending || !domainInput.trim()}
onClick={() =>
domainMutation.mutate(() => addServiceDomain(serviceId, domainInput.trim()), {
onSuccess: () => setDomainInput(''),
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
Add
</button>
</div>
{domainMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">{(domainMutation.error as Error).message}</p>
)}
<p className="mt-2 text-[10px] text-[var(--text-tertiary)]">Point the domain at this node (A record or CNAME), then Check DNS. Applies on next deploy or redeploy.</p>
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-[var(--text-primary)]">Sleep</h3>
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
Scale to zero after idle minutes without traffic. Requests resume the service automatically.
</p>
</div>
{service.status === 'sleeping' ? (
<button
type="button"
disabled={wakeMutation.isPending}
onClick={() => wakeMutation.mutate()}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
>
{wakeMutation.isPending ? 'Waking…' : 'Wake now'}
</button>
) : (
<button
type="button"
disabled={sleepMutation.isPending || service.status !== 'running'}
onClick={() => sleepMutation.mutate()}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors disabled:opacity-50"
>
{sleepMutation.isPending ? 'Sleeping…' : 'Sleep now'}
</button>
)}
</div>
<div className="flex items-center gap-4 text-xs">
<label className="flex items-center gap-2 text-[var(--text-primary)]">
<input
type="checkbox"
checked={sleepEnabled}
onChange={(e) =>
setSleepDraft({ id: serviceId, enabled: e.target.checked, minutes: sleepMinutes })
}
/>
Sleep on idle
</label>
<label className="flex items-center gap-2 text-[var(--text-tertiary)]">
after
<input
type="number"
min={1}
max={1440}
value={sleepMinutes}
onChange={(e) =>
setSleepDraft({ id: serviceId, enabled: sleepEnabled, minutes: e.target.value })
}
className="w-20 px-2 py-1 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
/>
min
</label>
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() =>
updateServiceMutation.mutate({
sleep_enabled: sleepEnabled,
sleep_idle_minutes: Math.max(1, Math.min(1440, Number(sleepMinutes) || 15)),
})
}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
>
Save
</button>
{sleepDraftCurrent && (
<span className="text-[var(--text-tertiary)]">unsaved</span>
)}
</div>
</div>
)}
{!isDemoMode && (nodesQuery.data?.length ?? 0) > 0 && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="mb-3">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Placement</h3>
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
Which node runs this service's containers. Remote nodes pull the image
from its registry — git builds and domains stay on the local host.
</p>
</div>
<div className="flex items-center gap-3 text-xs">
<select
value={service.spread ? '__spread__' : (nodeDraft ?? (service.nodeId || 'local'))}
disabled={service.spread}
onChange={(e) => setNodeDraft(e.target.value)}
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] disabled:opacity-50"
>
{service.spread && <option value="__spread__">All nodes (spread)</option>}
<option value="local">This server (local Docker)</option>
<option value="auto">Auto — least-loaded online node</option>
{(nodesQuery.data ?? []).map((n) => (
<option key={n.id} value={n.id}>
{n.name}{n.status !== 'online' ? ' (offline)' : ''}
</option>
))}
</select>
<label className="flex items-center gap-1.5 text-[var(--text-secondary)]">
<input
type="checkbox"
checked={service.spread ?? false}
disabled={updateServiceMutation.isPending || (!!service.nodeId && !service.spread)}
title={service.nodeId && !service.spread ? 'Clear the node pin first (choose "This server")' : undefined}
onChange={(e) => updateServiceMutation.mutate({ spread: e.target.checked })}
className="accent-[var(--accent-primary)]"
/>
spread replicas across nodes
</label>
{nodeDraft !== null && (
<>
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => updateServiceMutation.mutate({ node_id: nodeDraft })}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
>
Save
</button>
<button
type="button"
onClick={() => setNodeDraft(null)}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
Cancel
</button>
</>
)}
{service.nodeName && nodeDraft === null && (
<span className="text-[var(--text-tertiary)]">running on {service.nodeName}</span>
)}
</div>
{updateServiceMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Access</h3>
{accessForm === null && (
<button
type="button"
onClick={() =>
setAccessForm({ maintenance: Boolean(service.maintenanceMode), basicAuth: '' })
}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{accessForm === null ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Maintenance mode</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.maintenanceMode ? 'on' : 'off'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Basic auth</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'}
</p>
</div>
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
checked={accessForm.maintenance}
onChange={(e) => setAccessForm({ ...accessForm, maintenance: e.target.checked })}
/>
Maintenance mode — redirect all traffic to the maintenance page
</label>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Basic auth credentials (user:password per line — replaces all; empty clears)</label>
<textarea
value={accessForm.basicAuth}
onChange={(e) => setAccessForm({ ...accessForm, basicAuth: e.target.value })}
rows={2}
placeholder="admin:s3cret"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => {
const creds = accessForm.basicAuth
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((l) => {
const i = l.indexOf(':');
return i > 0 ? { username: l.slice(0, i), password: l.slice(i + 1) } : null;
})
.filter((x): x is { username: string; password: string } => x !== null);
updateServiceMutation.mutate(
{ maintenance_mode: accessForm.maintenance, basic_auth: creds },
{ onSuccess: () => setAccessForm(null) },
);
}}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setAccessForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Applies on next deploy or redeploy</p>
</div>
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Build &amp; Resources</h3>
{buildForm === null && (
<button
type="button"
onClick={() =>
setBuildForm({
builder: service.builder ?? 'auto',
cpuReserve: service.cpuReserve ?? '',
memoryReserve: service.memoryReserve ?? '',
staticCmd: service.staticBuildCmd ?? '',
staticDir: service.staticDir ?? '',
})
}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Edit
</button>
)}
</div>
{buildForm === null ? (
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Builder</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">{service.builder ?? 'auto'}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Reservations</p>
<p className="mt-1 text-sm font-medium text-[var(--text-primary)]">
{[service.cpuReserve && `cpu ${service.cpuReserve}`, service.memoryReserve && `mem ${service.memoryReserve}`]
.filter(Boolean).join(' · ') || 'none'}
</p>
</div>
{service.builder === 'static' && (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-3 col-span-2">
<p className="text-[var(--text-tertiary)] uppercase tracking-wide">Static build</p>
<p className="mt-1 text-sm font-medium mono text-[var(--text-primary)]">
{service.staticBuildCmd || 'npm ci && npm run build'} → {service.staticDir || 'dist'}
</p>
</div>
)}
</div>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Builder</label>
<select
value={buildForm.builder}
onChange={(e) => setBuildForm({ ...buildForm, builder: e.target.value })}
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
{['auto', 'railpack', 'nixpacks', 'dockerfile', 'static'].map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">CPU reservation (e.g. 0.25)</label>
<input
value={buildForm.cpuReserve}
onChange={(e) => setBuildForm({ ...buildForm, cpuReserve: e.target.value })}
placeholder="0.25"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Memory reservation (e.g. 128Mi)</label>
<input
value={buildForm.memoryReserve}
onChange={(e) => setBuildForm({ ...buildForm, memoryReserve: e.target.value })}
placeholder="128Mi"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
</div>
{buildForm.builder === 'static' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Build command</label>
<input
value={buildForm.staticCmd}
onChange={(e) => setBuildForm({ ...buildForm, staticCmd: e.target.value })}
placeholder="npm ci && npm run build"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Output directory</label>
<input
value={buildForm.staticDir}
onChange={(e) => setBuildForm({ ...buildForm, staticDir: e.target.value })}
placeholder="dist"
className="w-full px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
</div>
</div>
)}
<div className="flex items-center gap-2">
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => {
updateServiceMutation.mutate(
{
builder: buildForm.builder as 'auto' | 'railpack' | 'nixpacks' | 'dockerfile' | 'static',
cpu_reserve: buildForm.cpuReserve,
memory_reserve: buildForm.memoryReserve,
static_build_cmd: buildForm.staticCmd,
static_dir: buildForm.staticDir,
},
{ onSuccess: () => setBuildForm(null) },
);
}}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{updateServiceMutation.isPending ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setBuildForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Cancel
</button>
<p className="text-[10px] text-[var(--text-tertiary)]">Builder applies to the next source build; reservations on next deploy</p>
</div>
{updateServiceMutation.isError && (
<p className="text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Deployment History</h3>
<button
onClick={() => deploymentsQuery.refetch()}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={12} />
Refresh
</button>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden">
<table className="min-w-full text-xs">
<thead className="bg-[var(--surface-muted)]">
<tr>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Status</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">ID</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Image</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Created</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-subtle)]">
{deploymentsQuery.isLoading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--text-muted)]">
Loading...
</td>
</tr>
) : deploymentsQuery.isError ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--error)]">
Failed to load
</td>
</tr>
) : (deploymentsQuery.data?.length ?? 0) === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--text-muted)]">
No deployments yet
</td>
</tr>
) : (
deploymentsQuery.data!.map((deployment) => (
<tr key={deployment.id} className="hover:bg-[var(--surface-muted)]/50 transition-colors">
<td className="px-4 py-3">
<StatusBadge status={deployment.status} />
</td>
<td className="px-4 py-3 mono text-[var(--text-secondary)]">{deployment.id}</td>
<td className="px-4 py-3 mono text-[var(--text-secondary)]">
{deployment.imageName || '—'}:{deployment.imageTag || '—'}
</td>
<td className="px-4 py-3 text-[var(--text-tertiary)]">
{deployment.createdAt ? formatRelative(deployment.createdAt) : '—'}
</td>
<td className="px-4 py-3">
<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>
{['queued', 'pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status) && (
<button
onClick={() => {
if (window.confirm(`Cancel deployment ${deployment.id.slice(0, 8)}?`)) {
cancelDeploymentMutation.mutate(deployment.id);
}
}}
disabled={cancelDeploymentMutation.isPending}
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--error)] hover:border-[var(--error)]/50 disabled:opacity-50 transition-colors"
>
{cancelDeploymentMutation.isPending && cancelDeploymentMutation.variables === deployment.id ? '...' : 'Cancel'}
</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>
))
)}
</tbody>
</table>
</div>
</div>
)}
{!isDemoMode && signedIn && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Lifecycle</h3>
{lifecycleForm === null && (
<button
type="button"
onClick={() => setLifecycleForm({ cloneName: `${service.name}-copy`, cloneProject: '', moveTarget: '' })}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
Manage
</button>
)}
</div>
{lifecycleForm === null ? (
<p className="text-xs text-[var(--text-tertiary)]">
Duplicate this service into the same or another project, move it between projects, or delete it.
</p>
) : (
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] p-4 space-y-4">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Clone</label>
<div className="flex flex-wrap items-center gap-2">
<input
value={lifecycleForm.cloneName}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneName: e.target.value })}
placeholder={`${service.name}-copy`}
className="w-52 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]"
/>
<select
value={lifecycleForm.cloneProject}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, cloneProject: e.target.value })}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="">same project</option>
{(projectsQuery.data ?? [])
.filter((p) => p.id !== projectId)
.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button
type="button"
disabled={cloneMutation.isPending}
onClick={() =>
cloneMutation.mutate({
name: lifecycleForm.cloneName.trim() || undefined,
project_id: lifecycleForm.cloneProject || undefined,
})
}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-sm font-medium disabled:opacity-50"
>
{cloneMutation.isPending ? 'Cloning…' : 'Clone'}
</button>
</div>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
Copies config, volumes, domains, and variables. The clone starts stopped.
</p>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Move to project</label>
<div className="flex flex-wrap items-center gap-2">
<select
value={lifecycleForm.moveTarget}
onChange={(e) => setLifecycleForm({ ...lifecycleForm, moveTarget: e.target.value })}
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="">choose a project…</option>
{(projectsQuery.data ?? [])
.filter((p) => p.id !== projectId)
.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button
type="button"
disabled={moveMutation.isPending || !lifecycleForm.moveTarget}
onClick={() => {
if (window.confirm(`Move ${service.name} to another project?`)) {
moveMutation.mutate(lifecycleForm.moveTarget);
}
}}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
>
{moveMutation.isPending ? 'Moving…' : 'Move'}
</button>
</div>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
Variables, domains, and volumes move with the service. Running containers keep running.
</p>
</div>
<div className="pt-3 border-t border-[var(--border-subtle)]">
<label className="block text-xs text-[var(--error)] mb-1">Delete service</label>
<button
type="button"
disabled={deleteServiceMutation.isPending}
onClick={() => {
if (window.confirm(`Delete ${service.name}? This removes its containers and cannot be undone.`)) {
deleteServiceMutation.mutate();
}
}}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
>
{deleteServiceMutation.isPending ? 'Deleting…' : 'Delete'}
</button>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setLifecycleForm(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
Close
</button>
{(cloneMutation.isError || moveMutation.isError) && (
<p className="text-xs text-[var(--error)]">
{((cloneMutation.error ?? moveMutation.error) as Error)?.message}
</p>
)}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Error Toasts */}
{!isDemoMode && (deleteServiceMutation.error || deployMutation.error || rollbackMutation.error) && (
<div className="fixed bottom-4 right-4 space-y-2">
{deleteServiceMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(deleteServiceMutation.error as Error).message}
</div>
)}
{deployMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(deployMutation.error as Error).message}
</div>
)}
{rollbackMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(rollbackMutation.error as Error).message}
</div>
)}
</div>
)}
</div>
);
}