feat: real service runtime — private networks, replicas, lifecycle, rollback

Services now reconcile into actual Docker containers instead of
bookkeeping rows:

- Per-project bridge network with service-name DNS aliases and
  ${{service.KEY}} env expansion (HOST/PORT builtins)
- Replicas, published ports, Traefik domain routing, health checks,
  restart policies persisted on the service spec; spec-hash drift
  recreates containers on redeploy
- Lifecycle API: start/stop/restart/redeploy, live runtime state,
  real image rollback (replaces the fake sleep)
- Manual scale and PUT replicas reconcile live containers; service
  and project delete remove their containers and the project network
- Canvas: real URLs, replica badges, env filter, right-click
  deploy/restart/stop/delete menu, empty state
- Service detail: real lifecycle buttons, live replica/URL strip,
  Networking editor, ${{ }} variable helper
- Fix prebuilt-image pulls racing the tag (drain the pull stream)

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-21 19:31:19 +02:00
1 parent c16b32f56a
commit ff356230fe
20 files changed
+2270 -243

No files matched your search

+70
View File
@@ -48,6 +48,26 @@ export type ServiceEntity = {
buildPath?: string;
cpu?: string;
memory?: string;
replicas?: number;
port?: number;
domain?: string;
healthcheckPath?: string;
restartPolicy?: string;
publicUrl?: string;
};
export type RuntimeContainer = {
id: string;
name: string;
state: string;
replica: number;
};
export type ServiceRuntime = {
desired: number;
status: 'running' | 'degraded' | 'stopped';
urls: string[];
containers: RuntimeContainer[];
};
export type ServiceVariable = {
@@ -343,6 +363,7 @@ type RawNodeAgent = components['schemas']['NodeAgent'];
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
export type CreateServiceInput = components['schemas']['CreateServiceRequest'];
export type UpdateServiceInput = components['schemas']['UpdateServiceRequest'];
export class ApiError extends Error {
readonly status: number;
@@ -515,6 +536,12 @@ function normalizeService(service: RawService): ServiceEntity | null {
buildPath: service.build_path,
cpu: service.cpu,
memory: service.memory,
replicas: service.replicas,
port: service.port,
domain: service.domain,
healthcheckPath: service.healthcheck_path,
restartPolicy: service.restart_policy,
publicUrl: service.public_url,
};
}
@@ -998,12 +1025,55 @@ export async function createService(projectId: string, input: CreateServiceInput
return parsed;
}
export async function updateService(serviceId: string, input: UpdateServiceInput): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`, {
method: 'PUT',
body: JSON.stringify(input),
});
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
const parsed = normalizeService(row);
if (!parsed) {
throw new ApiError('Update service response is invalid', 500);
}
return parsed;
}
export async function deleteService(serviceId: string): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}`, {
method: 'DELETE',
});
}
export async function getServiceRuntime(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/runtime`);
return (
payload.runtime ?? {
desired: 0,
status: 'stopped',
urls: [],
containers: [],
}
);
}
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
}
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
method: 'POST',
});
return payload.runtime ?? { desired: 0, status: 'stopped', urls: [], containers: [] };
}
function normalizeServiceVariables(rows: RawServiceVariable[] | undefined): ServiceVariable[] {
const result: ServiceVariable[] = [];
for (const row of rows ?? []) {