mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Merge pull request #10 from Dvorinka/feat/fullscreen-canvas
feat(canvas): fullscreen Railway-style workspace canvas
This commit is contained in:
15 files changed
+1800
-380
No files matched your search
@@ -116,10 +116,12 @@ export default function App() {
|
||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||
|
||||
{/* Public browsing — approved projects, templates, docs. */}
|
||||
<Route element={<PlatformShell />}>
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
{/* Project workspace renders outside the shell — fullscreen canvas, no nav chrome */}
|
||||
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
|
||||
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
|
||||
|
||||
<Route element={<PlatformShell />}>
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/databases" element={<DatabasesPage />} />
|
||||
<Route path="/ha" element={<HighAvailabilityPage />} />
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
type FailoverPolicy,
|
||||
} from '@/lib/api-client';
|
||||
import { demoDatabases } from '@/lib/demo-data';
|
||||
import { ServiceIcon } from '@/features/workspace/canvas/ServiceIcon';
|
||||
import { serviceAccent } from '@/features/workspace/canvas/service-visuals';
|
||||
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
@@ -1668,8 +1670,14 @@ export function DatabasesPage() {
|
||||
onClick={() => setExpandedId(expanded ? null : (db.id ?? null))}
|
||||
className="flex w-full items-center gap-3 p-4 text-left"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center shrink-0">
|
||||
<Database size={17} className="text-[var(--accent-primary)]" />
|
||||
<div
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
background: `${serviceAccent({ name: db.type })}1c`,
|
||||
color: serviceAccent({ name: db.type }),
|
||||
}}
|
||||
>
|
||||
<ServiceIcon service={{ name: db.type, type: 'database' }} size={17} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
type CronJobEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
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';
|
||||
@@ -134,7 +136,10 @@ export function ServiceDetailPage() {
|
||||
});
|
||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics');
|
||||
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);
|
||||
@@ -382,7 +387,7 @@ export function ServiceDetailPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||
navigate(`/projects/${projectId}`);
|
||||
navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -595,13 +600,12 @@ export function ServiceDetailPage() {
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '46px',
|
||||
background: 'var(--accent-primary)',
|
||||
background: `${serviceAccent(service)}1f`,
|
||||
color: serviceAccent(service),
|
||||
marginRight: '14px'
|
||||
}}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
|
||||
</svg>
|
||||
<ServiceIcon service={service} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center" style={{ gap: '10px' }}>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { serviceIcon, serviceIconUrl, serviceTypeIcon, type ServiceIconInput } from './service-visuals';
|
||||
|
||||
export function ServiceIcon({
|
||||
service,
|
||||
size = 16,
|
||||
}: {
|
||||
service: ServiceIconInput;
|
||||
size?: number;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const brand = serviceIcon(service);
|
||||
if (brand && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={serviceIconUrl(brand)}
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return serviceTypeIcon(service.type ?? 'service', size);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import type { ServiceVariable } from '../auto-connections';
|
||||
import { inferAutoConnections } from '../auto-connections';
|
||||
|
||||
export const NODE_WIDTH = 240;
|
||||
export const NODE_HEIGHT = 104;
|
||||
// Multiples of the 26px canvas grid so laid-out nodes land on the lattice.
|
||||
export const COL_GAP = 338;
|
||||
export const ROW_GAP = 182;
|
||||
|
||||
type LinkEdge = { sourceServiceId: string; targetServiceId: string };
|
||||
|
||||
// Layered left-to-right layout: a service's depth is the longest chain of
|
||||
// dependencies beneath it, so consumers sit left and datastores right.
|
||||
// Unconnected services get their own trailing column separated by a gap.
|
||||
export function computeLayeredLayout(
|
||||
services: ServiceEntity[],
|
||||
edges: LinkEdge[],
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
if (services.length === 0) {
|
||||
return positions;
|
||||
}
|
||||
|
||||
const ids = new Set(services.map((service) => service.id));
|
||||
const outgoing = new Map<string, string[]>();
|
||||
const connected = new Set<string>();
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!ids.has(edge.sourceServiceId) || !ids.has(edge.targetServiceId)) {
|
||||
continue;
|
||||
}
|
||||
connected.add(edge.sourceServiceId);
|
||||
connected.add(edge.targetServiceId);
|
||||
const list = outgoing.get(edge.sourceServiceId) ?? [];
|
||||
list.push(edge.targetServiceId);
|
||||
outgoing.set(edge.sourceServiceId, list);
|
||||
}
|
||||
|
||||
const depthMemo = new Map<string, number>();
|
||||
const depthOf = (id: string, trail: Set<string>): number => {
|
||||
const memoized = depthMemo.get(id);
|
||||
if (memoized !== undefined) {
|
||||
return memoized;
|
||||
}
|
||||
if (trail.has(id)) {
|
||||
return 0; // cycle guard
|
||||
}
|
||||
trail.add(id);
|
||||
let best = 0;
|
||||
for (const target of outgoing.get(id) ?? []) {
|
||||
best = Math.max(best, depthOf(target, trail) + 1);
|
||||
}
|
||||
trail.delete(id);
|
||||
depthMemo.set(id, best);
|
||||
return best;
|
||||
};
|
||||
|
||||
const connectedServices = services
|
||||
.filter((service) => connected.has(service.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const looseServices = services
|
||||
.filter((service) => !connected.has(service.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const depthById = new Map(connectedServices.map((service) => [service.id, depthOf(service.id, new Set())]));
|
||||
const maxDepth = Math.max(0, ...depthById.values());
|
||||
|
||||
// Column = maxDepth - depth so deeper consumers render further left.
|
||||
const columns = new Map<number, ServiceEntity[]>();
|
||||
for (const service of connectedServices) {
|
||||
const col = maxDepth - (depthById.get(service.id) ?? 0);
|
||||
const list = columns.get(col) ?? [];
|
||||
list.push(service);
|
||||
columns.set(col, list);
|
||||
}
|
||||
|
||||
const connectedColCount = columns.size;
|
||||
const maxRows = Math.max(
|
||||
1,
|
||||
...Array.from(columns.values()).map((list) => list.length),
|
||||
looseServices.length,
|
||||
);
|
||||
|
||||
const gridOffset = (value: number) => Math.round(value / 26) * 26;
|
||||
|
||||
for (const [col, list] of columns) {
|
||||
const offsetY = gridOffset(((maxRows - list.length) * ROW_GAP) / 2);
|
||||
list.forEach((service, row) => {
|
||||
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||
});
|
||||
}
|
||||
|
||||
looseServices.forEach((service, row) => {
|
||||
const col = connectedColCount > 0 ? connectedColCount : 0;
|
||||
const offsetY = gridOffset(((maxRows - looseServices.length) * ROW_GAP) / 2);
|
||||
positions.set(service.id, { x: col * COL_GAP, y: offsetY + row * ROW_GAP });
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function layoutFromVariables(
|
||||
services: ServiceEntity[],
|
||||
variablesByService: Record<string, ServiceVariable[]>,
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const links = inferAutoConnections(services, variablesByService);
|
||||
return computeLayeredLayout(
|
||||
services,
|
||||
links.map((link) => link.edge),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { serviceStatusClass } from '@/lib/api-client';
|
||||
import type { ServiceVariable } from '../auto-connections';
|
||||
import { ServiceIcon } from './ServiceIcon';
|
||||
import { serviceAccent } from './service-visuals';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Link2,
|
||||
Loader2,
|
||||
Play,
|
||||
Rocket,
|
||||
Square,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type InspectorGroup = { id: string; title: string };
|
||||
export type InspectorConnection = {
|
||||
id: string;
|
||||
outbound: boolean;
|
||||
peer: string;
|
||||
peerId: string;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export type InspectorActions = {
|
||||
deploy: () => void;
|
||||
start: () => void;
|
||||
restart: () => void;
|
||||
stop: () => void;
|
||||
remove: () => void;
|
||||
pending: boolean;
|
||||
deployPending: boolean;
|
||||
restartPending: boolean;
|
||||
};
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'Online';
|
||||
case 'deployed':
|
||||
return 'Deployed';
|
||||
case 'building':
|
||||
return 'Building';
|
||||
case 'deploying':
|
||||
return 'Deploying';
|
||||
case 'pending':
|
||||
return 'Pending';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'stopped':
|
||||
return 'Stopped';
|
||||
case 'rolling_back':
|
||||
return 'Rolling back';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'deployed':
|
||||
return 'var(--success)';
|
||||
case 'failed':
|
||||
return 'var(--error)';
|
||||
case 'building':
|
||||
case 'deploying':
|
||||
case 'pending':
|
||||
case 'rolling_back':
|
||||
return 'var(--warning)';
|
||||
default:
|
||||
return 'var(--text-tertiary)';
|
||||
}
|
||||
}
|
||||
|
||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{title}</p>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLink({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="text-[10px] text-[var(--accent-primary)] hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
{children}
|
||||
<ArrowUpRight size={9} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceInspector({
|
||||
service,
|
||||
variables,
|
||||
connections,
|
||||
groups,
|
||||
groupId,
|
||||
readOnly,
|
||||
actions,
|
||||
onClose,
|
||||
onOpenService,
|
||||
onOpenSection,
|
||||
onSelectPeer,
|
||||
onAssignGroup,
|
||||
onNewGroupFor,
|
||||
}: {
|
||||
service: ServiceEntity;
|
||||
variables: ServiceVariable[];
|
||||
connections: InspectorConnection[];
|
||||
groups: InspectorGroup[];
|
||||
groupId: string | undefined;
|
||||
readOnly?: boolean;
|
||||
actions: InspectorActions;
|
||||
onClose: () => void;
|
||||
onOpenService: (serviceId: string) => void;
|
||||
onOpenSection: (serviceId: string, section: string) => void;
|
||||
onSelectPeer: (serviceId: string) => void;
|
||||
onAssignGroup: (serviceId: string, groupId: string | null) => void;
|
||||
onNewGroupFor: (serviceId: string) => void;
|
||||
}) {
|
||||
const accent = serviceAccent(service);
|
||||
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyAddress = () => {
|
||||
const text = publicUrl ?? (service.port ? `${service.name}:${service.port}` : service.name);
|
||||
void navigator.clipboard?.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
});
|
||||
};
|
||||
|
||||
const configRows: [string, string | undefined][] = [
|
||||
['Image', service.image],
|
||||
['Command', service.command],
|
||||
['Port', service.port ? String(service.port) : undefined],
|
||||
['Domain', service.domain],
|
||||
['Git repo', service.gitRepo],
|
||||
['Branch', service.gitBranch],
|
||||
['Build path', service.buildPath],
|
||||
['CPU', service.cpu],
|
||||
['Memory', service.memory],
|
||||
['Restart', service.restartPolicy],
|
||||
['Healthcheck', service.healthcheckPath],
|
||||
];
|
||||
const visibleConfig = configRows.filter(([, value]) => value);
|
||||
|
||||
return (
|
||||
<aside className="canvas-inspector absolute right-4 top-4 bottom-4 w-[312px] panel-glass flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 pt-4 pb-3 border-b border-[var(--border-subtle)]">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: `${accent}1c`, color: accent }}
|
||||
>
|
||||
<ServiceIcon service={service} size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">{service.name}</h3>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span style={{ color: statusColor(service.status) }}>{statusLabel(service.status)}</span>
|
||||
<span>· {service.type}</span>
|
||||
{service.environment && service.environment !== 'production' && <span>· {service.environment}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{(publicUrl || service.port) && (
|
||||
<Section title="Address">
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<Globe size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
href={publicUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mono text-[11px] text-[var(--accent-primary)] hover:underline break-all"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="mono text-[11px] text-[var(--text-secondary)]">
|
||||
{service.name}:{service.port}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAddress}
|
||||
className="ml-auto text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
title="Copy address"
|
||||
>
|
||||
{copied ? <span className="text-[10px] text-[var(--success)]">Copied</span> : <Copy size={11} />}
|
||||
</button>
|
||||
</div>
|
||||
{publicUrl && service.port ? (
|
||||
<p className="mono text-[10.5px] text-[var(--text-tertiary)] mt-0.5 pl-4">
|
||||
{service.name}:{service.port}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Group">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={11} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
<select
|
||||
value={groupId ?? ''}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
if (next === '__new__') {
|
||||
onNewGroupFor(service.id);
|
||||
return;
|
||||
}
|
||||
onAssignGroup(service.id, next === '' ? null : next);
|
||||
}}
|
||||
className="nodrag flex-1 h-7 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
<option value="">Ungrouped</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.title}
|
||||
</option>
|
||||
))}
|
||||
<option value="__new__">+ New group…</option>
|
||||
</select>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title={`Variables (${variables.length})`}
|
||||
action={
|
||||
<SectionLink onClick={() => onOpenSection(service.id, 'variables')}>
|
||||
Manage
|
||||
</SectionLink>
|
||||
}
|
||||
>
|
||||
{variables.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">No variables yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{variables.slice(0, 6).map((variable) => (
|
||||
<div
|
||||
key={variable.key}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/50"
|
||||
>
|
||||
<KeyRound size={9} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
<span className="mono text-[10px] text-[var(--text-primary)] truncate font-medium">
|
||||
{variable.key}
|
||||
</span>
|
||||
<span className="mono text-[10px] text-[var(--text-tertiary)] truncate ml-auto max-w-[130px]" title={variable.isSecret ? 'Secret' : variable.value}>
|
||||
{variable.isSecret ? '••••••••' : variable.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{variables.length > 6 && (
|
||||
<p className="text-[10px] text-[var(--text-muted)] pl-1">+{variables.length - 6} more</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Config"
|
||||
action={
|
||||
<SectionLink onClick={() => onOpenSection(service.id, 'config')}>
|
||||
Edit
|
||||
</SectionLink>
|
||||
}
|
||||
>
|
||||
{visibleConfig.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">No configuration set.</p>
|
||||
) : (
|
||||
<dl className="space-y-1">
|
||||
{visibleConfig.map(([label, value]) => (
|
||||
<div key={label} className="flex items-baseline gap-2">
|
||||
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">{label}</dt>
|
||||
<dd className="mono text-[10.5px] text-[var(--text-secondary)] truncate" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
{(service.replicas ?? 0) > 0 && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-[10px] text-[var(--text-muted)] w-[70px] flex-shrink-0">Replicas</dt>
|
||||
<dd className="mono text-[10.5px] text-[var(--text-secondary)]">{service.replicas}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={`Connections (${connections.length})`}>
|
||||
{connections.length === 0 ? (
|
||||
<p className="text-[11px] text-[var(--text-muted)]">
|
||||
No inferred connections. Reference another service via{' '}
|
||||
<span className="mono">{'{{variable}}'}</span> placeholders in env vars.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{connections.map((conn) => (
|
||||
<button
|
||||
key={conn.id}
|
||||
type="button"
|
||||
onClick={() => onSelectPeer(conn.peerId)}
|
||||
className="w-full text-left px-2.5 py-2 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/40 hover:bg-[var(--surface-muted)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--text-primary)]">
|
||||
<Link2 size={10} className="text-[var(--accent-primary)] flex-shrink-0" />
|
||||
<span className="truncate font-medium">{conn.outbound ? `→ ${conn.peer}` : `← ${conn.peer}`}</span>
|
||||
</div>
|
||||
<p className="mono text-[9.5px] text-[var(--text-tertiary)] truncate mt-0.5 pl-4">
|
||||
{conn.reasons.join(' · ')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{service.updatedAt && (
|
||||
<p className="text-[10px] text-[var(--text-muted)] pt-1">
|
||||
Updated {formatRelative(service.updatedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-subtle)] px-4 py-3 space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenService(service.id)}
|
||||
className="w-full flex items-center justify-center gap-2 h-9 rounded-[var(--radius-md)] text-[var(--accent-on)] text-[13px] font-medium"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<ArrowUpRight size={14} />
|
||||
Open service
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.deploy}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{actions.deployPending ? <Loader2 size={11} className="animate-spin" /> : <Rocket size={11} />}
|
||||
Deploy
|
||||
</button>
|
||||
{service.status !== 'running' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.start}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Play size={11} />
|
||||
Start
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.restart}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
{actions.restartPending ? <Loader2 size={11} className="animate-spin" /> : <Play size={11} />}
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly || service.status !== 'running'}
|
||||
onClick={actions.stop}
|
||||
className="flex items-center justify-center gap-1 h-8 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-[11px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Square size={11} />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actions.pending || readOnly}
|
||||
onClick={actions.remove}
|
||||
className="w-full flex items-center justify-center gap-1.5 h-8 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
Delete service
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { serviceStatusClass } from '@/lib/api-client';
|
||||
import { Box, Globe, Database, Terminal, ExternalLink, Copy, Layers } from 'lucide-react';
|
||||
import { Box, ExternalLink, Copy, Layers, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { serviceIcon, serviceAccent } from './service-visuals';
|
||||
import { ServiceIcon } from './ServiceIcon';
|
||||
|
||||
export type ServiceNodeData = {
|
||||
service: ServiceEntity;
|
||||
@@ -12,37 +14,13 @@ export type ServiceNodeData = {
|
||||
|
||||
export type GroupNodeData = {
|
||||
title: string;
|
||||
onRename?: (groupId: string, title: string) => void;
|
||||
renameNonce?: number;
|
||||
};
|
||||
|
||||
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
||||
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
||||
|
||||
function renderServiceTypeIcon(type: string, size = 18) {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return <Globe size={size} />;
|
||||
case 'database':
|
||||
return <Database size={size} />;
|
||||
case 'worker':
|
||||
return <Terminal size={size} />;
|
||||
default:
|
||||
return <Box size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
function serviceTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return '#7ab8ff';
|
||||
case 'database':
|
||||
return '#b4e34a';
|
||||
case 'worker':
|
||||
return 'var(--accent-primary)';
|
||||
default:
|
||||
return '#9295a4';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
@@ -72,27 +50,30 @@ function statusColor(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSIENT_STATUSES = new Set(['building', 'deploying', 'pending', 'rolling_back']);
|
||||
|
||||
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
const { service, selected } = data;
|
||||
const typeColor = serviceTypeColor(service.type);
|
||||
const brand = serviceIcon(service);
|
||||
const typeColor = brand?.color ?? serviceAccent(service);
|
||||
const replicas = service.replicas ?? 0;
|
||||
const stateColor = statusColor(service.status);
|
||||
const isTransient = TRANSIENT_STATUSES.has(service.status);
|
||||
|
||||
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
|
||||
className={`w-full rounded-[var(--radius-lg)] border transition-[border-color,box-shadow,transform] duration-150 group relative overflow-hidden ${
|
||||
selected
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-[0_0_0_1px_var(--accent-primary),0_12px_36px_rgba(0,0,0,0.5)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-[0_12px_32px_rgba(0,0,0,0.45)] hover:-translate-y-px'
|
||||
}`}
|
||||
style={{ minWidth: 220, maxWidth: 280 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||
style={{ background: `${typeColor}10` }}
|
||||
style={{ background: `${typeColor}08` }}
|
||||
/>
|
||||
|
||||
<Handle id="t-l" type="target" position={Position.Left} className="!opacity-0 !w-2 !h-2 !border-0" />
|
||||
@@ -105,22 +86,26 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
style={{ background: stateColor }}
|
||||
/>
|
||||
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="relative px-3.5 pt-3.5 pb-3">
|
||||
<div className="flex items-center gap-3 mb-2.5">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
|
||||
selected ? 'ring-2 ring-white/20' : ''
|
||||
}`}
|
||||
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||
style={{
|
||||
background: selected ? typeColor : `${typeColor}20`,
|
||||
color: selected ? 'white' : typeColor,
|
||||
background: selected && !brand ? typeColor : `${typeColor}1c`,
|
||||
color: selected && !brand ? 'var(--bg-void)' : typeColor,
|
||||
}}
|
||||
>
|
||||
{renderServiceTypeIcon(service.type, 18)}
|
||||
{isTransient ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<ServiceIcon service={service} size={17} />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||
{service.name}
|
||||
</h4>
|
||||
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||
{service.type}
|
||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||
</p>
|
||||
@@ -133,7 +118,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50 group/addr">
|
||||
<div className="flex items-center gap-2 mb-2.5 px-2.5 py-1.5 rounded-[var(--radius-sm)] bg-[var(--surface-muted)]/60 border border-[var(--border-subtle)]/60 group/addr">
|
||||
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||
{publicUrl ? (
|
||||
<a
|
||||
@@ -141,12 +126,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-[10px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||
className="text-[10.5px] text-[var(--accent-primary)] truncate mono hover:underline"
|
||||
>
|
||||
{publicUrl.replace(/^https?:\/\//, '')}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||
<span className="text-[10.5px] text-[var(--text-secondary)] truncate mono" title="Internal network address">
|
||||
{internalAddr}
|
||||
</span>
|
||||
)}
|
||||
@@ -164,14 +149,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
||||
<span className="text-[11px] font-semibold tracking-wide uppercase" style={{ color: stateColor }}>
|
||||
<span className="text-[10px] font-semibold tracking-[0.08em] uppercase" style={{ color: stateColor }}>
|
||||
{statusLabel(service.status)}
|
||||
</span>
|
||||
</div>
|
||||
{service.image && (
|
||||
<span className="text-[10px] text-[var(--text-tertiary)] truncate mono max-w-[120px]" title={service.image}>
|
||||
<span className="text-[9.5px] text-[var(--text-muted)] truncate mono max-w-[110px]" title={service.image}>
|
||||
{service.image}
|
||||
</span>
|
||||
)}
|
||||
@@ -181,29 +166,40 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
||||
export function GroupNode({ id, data }: NodeProps<GroupNodeType>) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(data.title);
|
||||
|
||||
// Context-menu rename requests bump the nonce; enter edit mode when it
|
||||
// changes. Render-phase state adjustment — the React-sanctioned pattern.
|
||||
const [seenRenameNonce, setSeenRenameNonce] = useState(data.renameNonce ?? 0);
|
||||
const incomingNonce = data.renameNonce ?? 0;
|
||||
if (incomingNonce > seenRenameNonce) {
|
||||
setSeenRenameNonce(incomingNonce);
|
||||
setDraft(data.title);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
const next = draft.trim() || data.title;
|
||||
setEditing(false);
|
||||
if (next !== data.title) {
|
||||
data.onRename?.(id, next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
|
||||
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative"
|
||||
style={{
|
||||
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
|
||||
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
|
||||
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
|
||||
style={{
|
||||
background: 'rgba(232,49,106,0.03)',
|
||||
opacity: isHovered ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||
@@ -214,8 +210,34 @@ export function GroupNode({ data }: NodeProps<GroupNodeType>) {
|
||||
>
|
||||
<Box size={14} />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
||||
</div>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(data.title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
className="nodrag h-7 px-2 rounded-[var(--radius-sm)] border border-[var(--border-default)] bg-[var(--surface-card)] text-sm font-semibold text-[var(--text-primary)] outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className="text-sm font-semibold text-[var(--text-primary)] tracking-tight"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDraft(data.title);
|
||||
setEditing(true);
|
||||
}}
|
||||
title="Double-click to rename"
|
||||
>
|
||||
{data.title}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Box, Globe, Database, Terminal, Clock } from 'lucide-react';
|
||||
|
||||
export function serviceTypeIcon(type: string, size = 18) {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return <Globe size={size} />;
|
||||
case 'database':
|
||||
return <Database size={size} />;
|
||||
case 'worker':
|
||||
return <Terminal size={size} />;
|
||||
case 'cron':
|
||||
return <Clock size={size} />;
|
||||
default:
|
||||
return <Box size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function serviceTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'web':
|
||||
return '#7ab8ff';
|
||||
case 'database':
|
||||
return '#b4e34a';
|
||||
case 'worker':
|
||||
return '#f2c94c';
|
||||
case 'cron':
|
||||
return '#f2994a';
|
||||
default:
|
||||
return '#9295a4';
|
||||
}
|
||||
}
|
||||
|
||||
export type ServiceBrandIcon = {
|
||||
slug: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const TECH_ICONS: (ServiceBrandIcon & { patterns: RegExp[] })[] = [
|
||||
{ slug: 'postgresql', color: '#7ab8ff', patterns: [/postgres/i, /postgis/i] },
|
||||
{ slug: 'redis', color: '#ff6b6b', patterns: [/redis/i, /dragonfly/i, /valkey/i, /keydb/i] },
|
||||
{ slug: 'mysql', color: '#5ba4e5', patterns: [/mysql/i] },
|
||||
{ slug: 'mariadb', color: '#4ea5ff', patterns: [/mariadb/i] },
|
||||
{ slug: 'mongodb', color: '#47a248', patterns: [/mongo/i] },
|
||||
{ slug: 'clickhouse', color: '#ffcc01', patterns: [/clickhouse/i] },
|
||||
{ slug: 'couchdb', color: '#e42528', patterns: [/couchdb/i] },
|
||||
{ slug: 'sqlite', color: '#69b7e4', patterns: [/sqlite/i] },
|
||||
{ slug: 'influxdb', color: '#22adf6', patterns: [/influx/i] },
|
||||
{ slug: 'elasticsearch', color: '#fec514', patterns: [/elastic/i, /opensearch/i] },
|
||||
{ slug: 'meilisearch', color: '#ff5caa', patterns: [/meili/i] },
|
||||
{ slug: 'typesense', color: '#d90368', patterns: [/typesense/i] },
|
||||
{ slug: 'qdrant', color: '#dc244c', patterns: [/qdrant/i] },
|
||||
{ slug: 'weaviate', color: '#01c9a7', patterns: [/weaviate/i] },
|
||||
{ slug: 'minio', color: '#c72e49', patterns: [/minio/i] },
|
||||
{ slug: 'rabbitmq', color: '#ff6600', patterns: [/rabbitmq/i, /rabbit/i] },
|
||||
{ slug: 'apachekafka', color: '#d6dae4', patterns: [/kafka/i] },
|
||||
{ slug: 'natsdotio', color: '#8be1d3', patterns: [/\bnats/i] },
|
||||
{ slug: 'nginx', color: '#009639', patterns: [/nginx/i] },
|
||||
{ slug: 'caddy', color: '#1f88c0', patterns: [/caddy/i] },
|
||||
{ slug: 'traefikproxy', color: '#24a1c1', patterns: [/traefik/i] },
|
||||
{ slug: 'nodedotjs', color: '#5fa04e', patterns: [/node(?=[:\-\d]|$)/i, /nodejs/i] },
|
||||
{ slug: 'go', color: '#00add8', patterns: [/golang/i, /(?:^|[^a-z])go(?=[:\-\d]|$)/i] },
|
||||
{ slug: 'python', color: '#6fa8dc', patterns: [/python/i, /\bpy(?:thon)?(?=[:\-\d]|$)/i] },
|
||||
{ slug: 'rust', color: '#f74c00', patterns: [/rust/i] },
|
||||
{ slug: 'php', color: '#8892bf', patterns: [/php/i] },
|
||||
{ slug: 'ruby', color: '#cc342d', patterns: [/ruby/i, /rails/i] },
|
||||
{ slug: 'openjdk', color: '#e6a23c', patterns: [/java/i, /openjdk/i, /jdk/i, /jre/i] },
|
||||
{ slug: 'dotnet', color: '#6f4bd8', patterns: [/dotnet/i, /aspnet/i] },
|
||||
{ slug: 'deno', color: '#70ffaf', patterns: [/\bdeno/i] },
|
||||
{ slug: 'bun', color: '#f9f1e1', patterns: [/bun(?=[:\-\d]|$)/i] },
|
||||
{ slug: 'react', color: '#61dafb', patterns: [/react/i] },
|
||||
{ slug: 'vuedotjs', color: '#4fc08d', patterns: [/vue/i] },
|
||||
{ slug: 'angular', color: '#dd0031', patterns: [/angular/i] },
|
||||
{ slug: 'svelte', color: '#ff3e00', patterns: [/svelte/i] },
|
||||
{ slug: 'nextdotjs', color: '#e6edf3', patterns: [/nextjs/i, /next\.js/i] },
|
||||
{ slug: 'nuxtdotjs', color: '#00dc82', patterns: [/nuxt/i] },
|
||||
{ slug: 'astro', color: '#ff5d01', patterns: [/astro/i] },
|
||||
{ slug: 'prometheus', color: '#e6522c', patterns: [/prometheus/i] },
|
||||
{ slug: 'grafana', color: '#f46800', patterns: [/grafana/i] },
|
||||
{ slug: 'uptimekuma', color: '#5cdd8b', patterns: [/uptime.?kuma/i, /\bkuma/i] },
|
||||
{ slug: 'n8n', color: '#ea4b71', patterns: [/n8n/i] },
|
||||
{ slug: 'wordpress', color: '#4b9cd3', patterns: [/wordpress/i, /\bwp-/i] },
|
||||
{ slug: 'ghost', color: '#a8b8c4', patterns: [/ghost/i] },
|
||||
{ slug: 'nextcloud', color: '#0082c9', patterns: [/nextcloud/i] },
|
||||
{ slug: 'immich', color: '#7c9ef5', patterns: [/immich/i] },
|
||||
{ slug: 'supabase', color: '#3fcf8e', patterns: [/supabase/i] },
|
||||
{ slug: 'pocketbase', color: '#b8dbe4', patterns: [/pocketbase/i] },
|
||||
{ slug: 'keycloak', color: '#d6dae4', patterns: [/keycloak/i] },
|
||||
{ slug: 'authentik', color: '#fd4b2d', patterns: [/authentik/i] },
|
||||
{ slug: 'portainer', color: '#13bef9', patterns: [/portainer/i] },
|
||||
{ slug: 'docker', color: '#2496ed', patterns: [/docker/i, /dind/i] },
|
||||
{ slug: 'kubernetes', color: '#326ce5', patterns: [/kubernetes/i, /\bk8s/i] },
|
||||
];
|
||||
|
||||
export type ServiceIconInput = { image?: string; name?: string; type?: string };
|
||||
|
||||
export function serviceIcon(service: ServiceIconInput): ServiceBrandIcon | null {
|
||||
const text = `${service.image ?? ''} ${service.name ?? ''}`.toLowerCase();
|
||||
for (const icon of TECH_ICONS) {
|
||||
if (icon.patterns.some((pattern) => pattern.test(text))) {
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function serviceIconUrl(icon: ServiceBrandIcon): string {
|
||||
return `https://cdn.simpleicons.org/${icon.slug}/${icon.color.replace('#', '')}`;
|
||||
}
|
||||
|
||||
export function serviceAccent(service: ServiceIconInput): string {
|
||||
return serviceIcon(service)?.color ?? serviceTypeColor(service.type ?? 'service');
|
||||
}
|
||||
@@ -44,14 +44,17 @@ export const DEFAULT_VIEWPORT: CanvasViewport = {
|
||||
zoom: 1,
|
||||
};
|
||||
|
||||
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
|
||||
export function createDefaultCanvasMetadata(
|
||||
services: ServiceEntity[],
|
||||
layout?: Map<string, CanvasPoint>,
|
||||
): ProjectCanvasMetadata {
|
||||
const nodes = services.map((service, index) => {
|
||||
const col = index % 3;
|
||||
const row = Math.floor(index / 3);
|
||||
|
||||
return {
|
||||
serviceId: service.id,
|
||||
position: {
|
||||
position: layout?.get(service.id) ?? {
|
||||
x: 70 + col * 260,
|
||||
y: 80 + row * 170,
|
||||
},
|
||||
|
||||
@@ -620,6 +620,8 @@ export function ProjectWorkspacePage() {
|
||||
isDemoMode
|
||||
? `/projects/${projectId}/services/${serviceId}?demo=1`
|
||||
: `/projects/${projectId}/services/${serviceId}`;
|
||||
const serviceSectionHref = (serviceId: string, section: string) =>
|
||||
`${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`;
|
||||
|
||||
if (!isDemoMode && projectQuery.isLoading) {
|
||||
return (
|
||||
@@ -644,7 +646,7 @@ export function ProjectWorkspacePage() {
|
||||
This project may have been deleted or you don't have access.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
onClick={() => navigate(isDemoMode ? '/projects?demo=1' : '/projects')}
|
||||
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"
|
||||
>
|
||||
Back to Projects
|
||||
@@ -654,91 +656,29 @@ export function ProjectWorkspacePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const canvasView = activeView === 'canvas';
|
||||
|
||||
const projectsHref = isDemoMode ? '/projects?demo=1' : '/projects';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
|
||||
<div className="w-full px-8 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-dvh min-h-0 flex-col bg-[var(--bg-void)]">
|
||||
{/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/80 px-4 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => navigate('/projects')}
|
||||
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
onClick={() => navigate(projectsHref)}
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Projects</span>
|
||||
<ArrowLeft size={14} />
|
||||
<span className="hidden sm:inline">Projects</span>
|
||||
</button>
|
||||
<div className="w-px h-5 bg-[var(--border-subtle)]" />
|
||||
<div>
|
||||
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-[var(--border-subtle)] shrink-0" />
|
||||
<h1 className="v-title text-sm shrink-0 min-w-0 truncate">{project.name}<span className="v-cursor">_</span></h1>
|
||||
<span className="hidden lg:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)] shrink-0">
|
||||
{services.length} service{services.length !== 1 ? 's' : ''} · {runningServices} running
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-[var(--border-subtle)]" />
|
||||
<LiveIndicator isLive={runningServices > 0} />
|
||||
<div className="w-px h-8 bg-[var(--border-subtle)]" />
|
||||
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
|
||||
<Clock size={12} />
|
||||
<span>{formatRelative(project.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search / Command Palette */}
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
<Search size={14} />
|
||||
<span>Search...</span>
|
||||
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
{/* Add Service Button */}
|
||||
{!isDemoMode && (
|
||||
<button
|
||||
onClick={openAddService}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-[var(--accent-on)] text-sm font-medium shadow-lg hover:shadow-xl transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span className="hidden sm:inline">Add Service</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="w-full px-8 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="lg:sticky lg:top-6 lg:h-fit">
|
||||
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
|
||||
{/* View tabs */}
|
||||
<nav className="flex items-center gap-0.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)] p-0.5 mx-2 overflow-x-auto min-w-0">
|
||||
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
||||
const active = activeView === item.key;
|
||||
const Icon = item.icon;
|
||||
@@ -750,35 +690,82 @@ export function ProjectWorkspacePage() {
|
||||
next.set('view', item.key);
|
||||
return next;
|
||||
})}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap ${
|
||||
active
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
{active && (
|
||||
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
|
||||
)}
|
||||
<Icon size={13} />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content Area */}
|
||||
<section className="min-w-0">
|
||||
{activeView === 'canvas' && (
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<div className="hidden xl:block">
|
||||
<LiveIndicator isLive={runningServices > 0} />
|
||||
</div>
|
||||
<span className="hidden xl:flex items-center gap-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
<Clock size={11} />
|
||||
{formatRelative(project.updatedAt)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
<Search size={12} />
|
||||
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||
</button>
|
||||
{!isDemoMode && (
|
||||
<button
|
||||
onClick={openAddService}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-[var(--accent-on)] text-xs font-semibold shadow-lg hover:shadow-xl transition-all"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span className="hidden sm:inline">Add Service</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{canvasView ? (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<ProjectCanvas
|
||||
projectId={project.id}
|
||||
services={services}
|
||||
variablesByService={variablesByService}
|
||||
onAddService={openAddService}
|
||||
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
||||
onOpenSection={(serviceId, section) => navigate(serviceSectionHref(serviceId, section))}
|
||||
readOnly={isDemoMode}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 pointer-events-none">
|
||||
<span className="canvas-pill flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-[var(--warning)]">
|
||||
<Sparkles size={12} />
|
||||
Demo data
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isDemoMode && (
|
||||
<div className="w-full px-8 pt-6">
|
||||
<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>
|
||||
)}
|
||||
<div className="w-full px-8 py-6">
|
||||
<section className="min-w-0">
|
||||
{activeView === 'observability' && (
|
||||
<div className="space-y-6">
|
||||
{/* Health Summary */}
|
||||
@@ -965,6 +952,7 @@ export function ProjectWorkspacePage() {
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Service Dialog */}
|
||||
{!isDemoMode && (
|
||||
|
||||
@@ -171,9 +171,13 @@ export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMet
|
||||
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
|
||||
export function loadCanvasMetadata(
|
||||
projectId: string,
|
||||
services: ServiceEntity[],
|
||||
layout?: Map<string, { x: number; y: number }>,
|
||||
): ProjectCanvasMetadata {
|
||||
const key = canvasStorageKey(projectId);
|
||||
const fallback = createDefaultCanvasMetadata(services);
|
||||
const fallback = createDefaultCanvasMetadata(services, layout);
|
||||
const serviceIds = new Set(services.map((service) => service.id));
|
||||
|
||||
const raw = canvasStorage()?.getItem(key);
|
||||
@@ -195,7 +199,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
|
||||
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
|
||||
const normalizedNodes: CanvasNodeLayout[] = [];
|
||||
|
||||
for (const [index, service] of services.entries()) {
|
||||
// Services added after the layout was saved park in a trailing column to
|
||||
// the right of everything placed so far, instead of overlapping nodes the
|
||||
// user already arranged.
|
||||
const placedXs = parsed.nodes.map((node) => node.position.x);
|
||||
let parkedRow = 0;
|
||||
const parkedX = placedXs.length > 0 ? Math.max(...placedXs) + 320 : 70;
|
||||
|
||||
for (const service of services) {
|
||||
const existing = nodeMap.get(service.id);
|
||||
if (existing) {
|
||||
normalizedNodes.push({
|
||||
@@ -206,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
|
||||
continue;
|
||||
}
|
||||
|
||||
const col = index % 3;
|
||||
const row = Math.floor(index / 3);
|
||||
const parked = layout?.get(service.id);
|
||||
normalizedNodes.push({
|
||||
serviceId: service.id,
|
||||
position: {
|
||||
x: 70 + col * 260,
|
||||
y: 80 + row * 170,
|
||||
},
|
||||
position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 },
|
||||
});
|
||||
if (!parked) {
|
||||
parkedRow += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const seenEdgeIds = new Set<string>();
|
||||
|
||||
@@ -634,13 +634,13 @@ textarea {
|
||||
.edge-premium .react-flow__edge-path {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 4px rgba(156, 126, 240, 0.3));
|
||||
filter: drop-shadow(0 0 4px rgba(180, 227, 74, 0.25));
|
||||
transition: filter 0.2s ease, stroke-width 0.2s ease;
|
||||
}
|
||||
|
||||
.edge-premium:hover .react-flow__edge-path {
|
||||
filter: drop-shadow(0 0 8px rgba(156, 126, 240, 0.5));
|
||||
stroke-width: 3px;
|
||||
filter: drop-shadow(0 0 8px rgba(180, 227, 74, 0.45));
|
||||
stroke-width: 2.6px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -1685,3 +1685,86 @@ textarea {
|
||||
margin: 24px 0;
|
||||
}
|
||||
.docs-article img { max-width: 100%; border-radius: var(--radius-md); }
|
||||
|
||||
/* ============================================
|
||||
CANVAS — fullscreen workspace graph
|
||||
============================================ */
|
||||
|
||||
/* Floating glass pills (zoom controls, env filter, hints) */
|
||||
.canvas-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: rgba(19, 21, 24, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.canvas-pill-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-tertiary);
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.canvas-pill-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
/* React Flow pane affordances */
|
||||
.react-flow__pane {
|
||||
cursor: grab;
|
||||
}
|
||||
.react-flow__pane.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.react-flow__node.selectable:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Marquee selection tint */
|
||||
.react-flow__selection {
|
||||
background: var(--accent-primary-soft);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
/* MiniMap — match surface card, dimmed nodes */
|
||||
.react-flow__minimap {
|
||||
border-radius: var(--radius-md) !important;
|
||||
border: 1px solid var(--border-default);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.react-flow__minimap-svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Inspector panel entrance */
|
||||
.canvas-inspector {
|
||||
animation: canvas-panel-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes canvas-panel-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas-inspector {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ export const demoProjects: ProjectEntity[] = [
|
||||
createdAt: '2026-03-12T09:30:00Z',
|
||||
updatedAt: '2026-03-31T09:10:00Z',
|
||||
stats: {
|
||||
service_count: 4,
|
||||
service_count: 5,
|
||||
deployment_count: 27,
|
||||
running_services: 3,
|
||||
running_services: 4,
|
||||
last_deployment: '2026-03-31T08:42:00Z',
|
||||
},
|
||||
},
|
||||
@@ -55,9 +55,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'web',
|
||||
status: 'running',
|
||||
environment: 'production',
|
||||
image: 'ghcr.io/containr/web:2026.03.31',
|
||||
image: 'ghcr.io/containr/web-react:v42',
|
||||
command: 'npm run serve',
|
||||
gitBranch: 'main',
|
||||
port: 3000,
|
||||
domain: 'web.containr.local',
|
||||
createdAt: '2026-03-12T10:00:00Z',
|
||||
updatedAt: '2026-03-31T08:42:00Z',
|
||||
},
|
||||
@@ -68,9 +70,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'web',
|
||||
status: 'running',
|
||||
environment: 'production',
|
||||
image: 'ghcr.io/containr/api:2026.03.31',
|
||||
image: 'ghcr.io/containr/api-go:v2.4.1',
|
||||
command: './server',
|
||||
gitBranch: 'main',
|
||||
port: 8080,
|
||||
domain: 'api.containr.local',
|
||||
createdAt: '2026-03-12T10:02:00Z',
|
||||
updatedAt: '2026-03-31T08:43:00Z',
|
||||
},
|
||||
@@ -81,9 +85,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'worker',
|
||||
status: 'building',
|
||||
environment: 'production',
|
||||
image: 'ghcr.io/containr/worker:2026.03.31',
|
||||
image: 'ghcr.io/containr/worker:edge',
|
||||
command: './worker',
|
||||
gitBranch: 'main',
|
||||
port: 9100,
|
||||
createdAt: '2026-03-12T10:05:00Z',
|
||||
updatedAt: '2026-03-31T08:44:00Z',
|
||||
},
|
||||
@@ -94,12 +99,27 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'database',
|
||||
status: 'running',
|
||||
environment: 'production',
|
||||
image: 'postgres:15-alpine',
|
||||
image: 'postgres:16',
|
||||
command: 'postgres',
|
||||
gitBranch: 'main',
|
||||
port: 5432,
|
||||
createdAt: '2026-03-12T10:08:00Z',
|
||||
updatedAt: '2026-03-31T08:40:00Z',
|
||||
},
|
||||
{
|
||||
id: 'demo-svc-redis',
|
||||
projectId: 'demo-project-core',
|
||||
name: 'Redis Cache',
|
||||
type: 'database',
|
||||
status: 'running',
|
||||
environment: 'production',
|
||||
image: 'dragonfly:1.25',
|
||||
command: 'redis-server',
|
||||
gitBranch: 'main',
|
||||
port: 6379,
|
||||
createdAt: '2026-03-12T10:12:00Z',
|
||||
updatedAt: '2026-03-31T08:40:00Z',
|
||||
},
|
||||
],
|
||||
'demo-project-growth': [
|
||||
{
|
||||
@@ -109,9 +129,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'web',
|
||||
status: 'running',
|
||||
environment: 'production',
|
||||
image: 'ghcr.io/containr/marketing:latest',
|
||||
command: 'npm run start',
|
||||
image: 'nginx:1.27-alpine',
|
||||
command: 'nginx -g daemon off;',
|
||||
gitBranch: 'main',
|
||||
port: 8080,
|
||||
domain: 'growth.containr.local',
|
||||
createdAt: '2026-03-03T09:00:00Z',
|
||||
updatedAt: '2026-03-30T12:10:00Z',
|
||||
},
|
||||
@@ -122,9 +144,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'web',
|
||||
status: 'running',
|
||||
environment: 'production',
|
||||
image: 'ghcr.io/containr/campaign-api:latest',
|
||||
image: 'node:20-alpine',
|
||||
command: './api',
|
||||
gitBranch: 'main',
|
||||
port: 3000,
|
||||
createdAt: '2026-03-03T09:04:00Z',
|
||||
updatedAt: '2026-03-30T12:08:00Z',
|
||||
},
|
||||
@@ -135,9 +158,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'database',
|
||||
status: 'running',
|
||||
environment: 'production',
|
||||
image: 'redis:7-alpine',
|
||||
image: 'dragonfly:1.25',
|
||||
command: 'redis-server',
|
||||
gitBranch: 'main',
|
||||
port: 6379,
|
||||
createdAt: '2026-03-03T09:07:00Z',
|
||||
updatedAt: '2026-03-30T12:07:00Z',
|
||||
},
|
||||
@@ -150,9 +174,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'web',
|
||||
status: 'running',
|
||||
environment: 'production',
|
||||
image: 'ghcr.io/containr/inference-api:latest',
|
||||
image: 'python:3.12-slim',
|
||||
command: './serve',
|
||||
gitBranch: 'main',
|
||||
port: 8000,
|
||||
createdAt: '2026-02-18T08:00:00Z',
|
||||
updatedAt: '2026-03-29T18:00:00Z',
|
||||
},
|
||||
@@ -163,7 +188,7 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
||||
type: 'worker',
|
||||
status: 'failed',
|
||||
environment: 'production',
|
||||
image: 'ghcr.io/containr/evaluator:latest',
|
||||
image: 'python:3.12-slim',
|
||||
command: './run-jobs',
|
||||
gitBranch: 'main',
|
||||
createdAt: '2026-02-18T08:03:00Z',
|
||||
@@ -195,6 +220,7 @@ export const demoVariablesByProject: Record<string, Record<string, ServiceVariab
|
||||
] satisfies ServiceVariable[]
|
||||
),
|
||||
'demo-svc-postgres': [],
|
||||
'demo-svc-redis': [],
|
||||
},
|
||||
'demo-project-growth': {
|
||||
'demo-growth-site': (
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 145 KiB |
Reference in new issue
Block a user