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
+1978
-558
No files matched your search
@@ -116,10 +116,12 @@ export default function App() {
|
|||||||
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
<Route path="/auth/sign-up" element={<SignUpPage />} />
|
||||||
|
|
||||||
{/* Public browsing — approved projects, templates, docs. */}
|
{/* Public browsing — approved projects, templates, docs. */}
|
||||||
|
{/* 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 element={<PlatformShell />}>
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
|
|
||||||
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
|
|
||||||
<Route path="/templates" element={<TemplatesPage />} />
|
<Route path="/templates" element={<TemplatesPage />} />
|
||||||
<Route path="/databases" element={<DatabasesPage />} />
|
<Route path="/databases" element={<DatabasesPage />} />
|
||||||
<Route path="/ha" element={<HighAvailabilityPage />} />
|
<Route path="/ha" element={<HighAvailabilityPage />} />
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import {
|
|||||||
type FailoverPolicy,
|
type FailoverPolicy,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { demoDatabases } from '@/lib/demo-data';
|
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 { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||||
import { useAuthSession } from '@/lib/use-auth-session';
|
import { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { formatRelative } from '@/lib/time';
|
import { formatRelative } from '@/lib/time';
|
||||||
@@ -1668,8 +1670,14 @@ export function DatabasesPage() {
|
|||||||
onClick={() => setExpandedId(expanded ? null : (db.id ?? null))}
|
onClick={() => setExpandedId(expanded ? null : (db.id ?? null))}
|
||||||
className="flex w-full items-center gap-3 p-4 text-left"
|
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">
|
<div
|
||||||
<Database size={17} className="text-[var(--accent-primary)]" />
|
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>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
type CronJobEntity,
|
type CronJobEntity,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
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 { useAuthSession } from '@/lib/use-auth-session';
|
||||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||||
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
||||||
@@ -134,7 +136,10 @@ export function ServiceDetailPage() {
|
|||||||
});
|
});
|
||||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
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 [logTail, setLogTail] = useState('100');
|
||||||
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
|
const [varDrafts, setVarDrafts] = useState<VariableDraft[] | null>(null);
|
||||||
const [bulkOpen, setBulkOpen] = useState(false);
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
@@ -382,7 +387,7 @@ export function ServiceDetailPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
navigate(`/projects/${projectId}`);
|
navigate(isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -595,13 +600,12 @@ export function ServiceDetailPage() {
|
|||||||
style={{
|
style={{
|
||||||
width: '46px',
|
width: '46px',
|
||||||
height: '46px',
|
height: '46px',
|
||||||
background: 'var(--accent-primary)',
|
background: `${serviceAccent(service)}1f`,
|
||||||
|
color: serviceAccent(service),
|
||||||
marginRight: '14px'
|
marginRight: '14px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
|
<ServiceIcon service={service} size={22} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center" style={{ gap: '10px' }}>
|
<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 { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
||||||
import type { ServiceEntity } from '@/lib/api-client';
|
import type { ServiceEntity } from '@/lib/api-client';
|
||||||
import { serviceStatusClass } 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 { useState } from 'react';
|
||||||
|
import { serviceIcon, serviceAccent } from './service-visuals';
|
||||||
|
import { ServiceIcon } from './ServiceIcon';
|
||||||
|
|
||||||
export type ServiceNodeData = {
|
export type ServiceNodeData = {
|
||||||
service: ServiceEntity;
|
service: ServiceEntity;
|
||||||
@@ -12,37 +14,13 @@ export type ServiceNodeData = {
|
|||||||
|
|
||||||
export type GroupNodeData = {
|
export type GroupNodeData = {
|
||||||
title: string;
|
title: string;
|
||||||
|
onRename?: (groupId: string, title: string) => void;
|
||||||
|
renameNonce?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
|
||||||
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
|
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 {
|
function statusLabel(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
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>) {
|
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
||||||
const { service, selected } = data;
|
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 replicas = service.replicas ?? 0;
|
||||||
const stateColor = statusColor(service.status);
|
const stateColor = statusColor(service.status);
|
||||||
|
const isTransient = TRANSIENT_STATUSES.has(service.status);
|
||||||
|
|
||||||
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
const publicUrl = service.domain ? `https://${service.domain}` : service.publicUrl;
|
||||||
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
const internalAddr = service.port ? `${service.name}:${service.port}` : service.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
selected
|
||||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
? '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-lg'
|
: '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
|
<div
|
||||||
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
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" />
|
<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 }}
|
style={{ background: stateColor }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative p-4">
|
<div className="relative px-3.5 pt-3.5 pb-3">
|
||||||
<div className="flex items-start gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-2.5">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
|
className="w-9 h-9 rounded-[var(--radius-md)] flex items-center justify-center flex-shrink-0 transition-all duration-200"
|
||||||
selected ? 'ring-2 ring-white/20' : ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
background: selected ? typeColor : `${typeColor}20`,
|
background: selected && !brand ? typeColor : `${typeColor}1c`,
|
||||||
color: selected ? 'white' : typeColor,
|
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>
|
||||||
<div className="min-w-0 flex-1 pt-0.5">
|
<div className="min-w-0 flex-1">
|
||||||
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
|
<h4 className="font-semibold text-[13px] leading-tight text-[var(--text-primary)] truncate tracking-tight">
|
||||||
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">
|
{service.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10.5px] text-[var(--text-tertiary)] truncate mt-0.5">
|
||||||
{service.type}
|
{service.type}
|
||||||
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
{service.environment && service.environment !== 'production' ? ` · ${service.environment}` : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -133,7 +118,7 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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" />
|
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
|
||||||
{publicUrl ? (
|
{publicUrl ? (
|
||||||
<a
|
<a
|
||||||
@@ -141,12 +126,12 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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?:\/\//, '')}
|
{publicUrl.replace(/^https?:\/\//, '')}
|
||||||
</a>
|
</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}
|
{internalAddr}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -164,14 +149,14 @@ export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
|
<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)}
|
{statusLabel(service.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{service.image && (
|
{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}
|
{service.image}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -181,41 +166,78 @@ 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 [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 (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
|
borderColor: isHovered ? 'var(--border-strong)' : 'var(--border-default)',
|
||||||
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30',
|
background: isHovered ? 'var(--accent-primary-soft)' : 'rgba(255,255,255,0.02)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
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="relative p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div
|
||||||
<div
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
|
style={{
|
||||||
style={{
|
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
||||||
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
|
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
||||||
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Box size={14} />
|
||||||
<Box size={14} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p
|
<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,
|
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 nodes = services.map((service, index) => {
|
||||||
const col = index % 3;
|
const col = index % 3;
|
||||||
const row = Math.floor(index / 3);
|
const row = Math.floor(index / 3);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
position: {
|
position: layout?.get(service.id) ?? {
|
||||||
x: 70 + col * 260,
|
x: 70 + col * 260,
|
||||||
y: 80 + row * 170,
|
y: 80 + row * 170,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -620,6 +620,8 @@ export function ProjectWorkspacePage() {
|
|||||||
isDemoMode
|
isDemoMode
|
||||||
? `/projects/${projectId}/services/${serviceId}?demo=1`
|
? `/projects/${projectId}/services/${serviceId}?demo=1`
|
||||||
: `/projects/${projectId}/services/${serviceId}`;
|
: `/projects/${projectId}/services/${serviceId}`;
|
||||||
|
const serviceSectionHref = (serviceId: string, section: string) =>
|
||||||
|
`${serviceHref(serviceId)}${isDemoMode ? '&' : '?'}section=${section}`;
|
||||||
|
|
||||||
if (!isDemoMode && projectQuery.isLoading) {
|
if (!isDemoMode && projectQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -644,7 +646,7 @@ export function ProjectWorkspacePage() {
|
|||||||
This project may have been deleted or you don't have access.
|
This project may have been deleted or you don't have access.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
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
|
Back to Projects
|
||||||
@@ -654,131 +656,116 @@ export function ProjectWorkspacePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canvasView = activeView === 'canvas';
|
||||||
|
|
||||||
|
const projectsHref = isDemoMode ? '/projects?demo=1' : '/projects';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="flex h-dvh min-h-0 flex-col bg-[var(--bg-void)]">
|
||||||
{/* Header */}
|
{/* Slim workspace bar — tabs + project meta, canvas fills everything below */}
|
||||||
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
|
<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">
|
||||||
<div className="w-full px-8 py-4">
|
<button
|
||||||
<div className="flex items-center justify-between gap-4">
|
onClick={() => navigate(projectsHref)}
|
||||||
<div className="flex items-center gap-4">
|
className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
<span className="hidden sm:inline">Projects</span>
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/projects')}
|
key={item.key}
|
||||||
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
onClick={() => setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.set('view', item.key);
|
||||||
|
return next;
|
||||||
|
})}
|
||||||
|
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(--surface-card)] text-[var(--text-primary)] shadow-sm'
|
||||||
|
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<Icon size={13} />
|
||||||
<span>Projects</span>
|
<span className="hidden md:inline">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-px h-5 bg-[var(--border-subtle)]" />
|
);
|
||||||
<div>
|
})}
|
||||||
<h1 className="v-title">{project.name}<span className="v-cursor">_</span></h1>
|
</nav>
|
||||||
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||||
{/* Stats */}
|
<div className="hidden xl:block">
|
||||||
<div className="hidden md:flex items-center gap-6">
|
<LiveIndicator isLive={runningServices > 0} />
|
||||||
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Demo Mode Banner */}
|
{/* Content */}
|
||||||
{isDemoMode && (
|
{canvasView ? (
|
||||||
<div className="w-full px-8 py-4">
|
<div className="relative flex-1 min-h-0">
|
||||||
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
|
<ProjectCanvas
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
|
projectId={project.id}
|
||||||
<Sparkles size={16} />
|
services={services}
|
||||||
<span>Demo mode active — using sample data for preview</span>
|
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>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<div className="w-full px-8 py-6">
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
{viewItems.filter((item) => signedIn || !item.authOnly).map((item) => {
|
|
||||||
const active = activeView === item.key;
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
onClick={() => setSearchParams((prev) => {
|
|
||||||
const next = new URLSearchParams(prev);
|
|
||||||
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 ${
|
|
||||||
active
|
|
||||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
|
||||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<section className="min-w-0">
|
<section className="min-w-0">
|
||||||
{activeView === 'canvas' && (
|
|
||||||
<ProjectCanvas
|
|
||||||
projectId={project.id}
|
|
||||||
services={services}
|
|
||||||
variablesByService={variablesByService}
|
|
||||||
onAddService={openAddService}
|
|
||||||
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeView === 'observability' && (
|
{activeView === 'observability' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Health Summary */}
|
{/* Health Summary */}
|
||||||
@@ -965,6 +952,7 @@ export function ProjectWorkspacePage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create Service Dialog */}
|
{/* Create Service Dialog */}
|
||||||
{!isDemoMode && (
|
{!isDemoMode && (
|
||||||
|
|||||||
@@ -171,9 +171,13 @@ export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMet
|
|||||||
canvasStorage()?.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
|
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 key = canvasStorageKey(projectId);
|
||||||
const fallback = createDefaultCanvasMetadata(services);
|
const fallback = createDefaultCanvasMetadata(services, layout);
|
||||||
const serviceIds = new Set(services.map((service) => service.id));
|
const serviceIds = new Set(services.map((service) => service.id));
|
||||||
|
|
||||||
const raw = canvasStorage()?.getItem(key);
|
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 nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
|
||||||
const normalizedNodes: CanvasNodeLayout[] = [];
|
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);
|
const existing = nodeMap.get(service.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
normalizedNodes.push({
|
normalizedNodes.push({
|
||||||
@@ -206,15 +217,14 @@ export function loadCanvasMetadata(projectId: string, services: ServiceEntity[])
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const col = index % 3;
|
const parked = layout?.get(service.id);
|
||||||
const row = Math.floor(index / 3);
|
|
||||||
normalizedNodes.push({
|
normalizedNodes.push({
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
position: {
|
position: parked ?? { x: parkedX, y: 80 + parkedRow * 160 },
|
||||||
x: 70 + col * 260,
|
|
||||||
y: 80 + row * 170,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!parked) {
|
||||||
|
parkedRow += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenEdgeIds = new Set<string>();
|
const seenEdgeIds = new Set<string>();
|
||||||
|
|||||||
@@ -634,13 +634,13 @@ textarea {
|
|||||||
.edge-premium .react-flow__edge-path {
|
.edge-premium .react-flow__edge-path {
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: 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;
|
transition: filter 0.2s ease, stroke-width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edge-premium:hover .react-flow__edge-path {
|
.edge-premium:hover .react-flow__edge-path {
|
||||||
filter: drop-shadow(0 0 8px rgba(156, 126, 240, 0.5));
|
filter: drop-shadow(0 0 8px rgba(180, 227, 74, 0.45));
|
||||||
stroke-width: 3px;
|
stroke-width: 2.6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@@ -1685,3 +1685,86 @@ textarea {
|
|||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
.docs-article img { max-width: 100%; border-radius: var(--radius-md); }
|
.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',
|
createdAt: '2026-03-12T09:30:00Z',
|
||||||
updatedAt: '2026-03-31T09:10:00Z',
|
updatedAt: '2026-03-31T09:10:00Z',
|
||||||
stats: {
|
stats: {
|
||||||
service_count: 4,
|
service_count: 5,
|
||||||
deployment_count: 27,
|
deployment_count: 27,
|
||||||
running_services: 3,
|
running_services: 4,
|
||||||
last_deployment: '2026-03-31T08:42:00Z',
|
last_deployment: '2026-03-31T08:42:00Z',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -55,9 +55,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'web',
|
type: 'web',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'ghcr.io/containr/web:2026.03.31',
|
image: 'ghcr.io/containr/web-react:v42',
|
||||||
command: 'npm run serve',
|
command: 'npm run serve',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
|
port: 3000,
|
||||||
|
domain: 'web.containr.local',
|
||||||
createdAt: '2026-03-12T10:00:00Z',
|
createdAt: '2026-03-12T10:00:00Z',
|
||||||
updatedAt: '2026-03-31T08:42:00Z',
|
updatedAt: '2026-03-31T08:42:00Z',
|
||||||
},
|
},
|
||||||
@@ -68,9 +70,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'web',
|
type: 'web',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'ghcr.io/containr/api:2026.03.31',
|
image: 'ghcr.io/containr/api-go:v2.4.1',
|
||||||
command: './server',
|
command: './server',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
|
port: 8080,
|
||||||
|
domain: 'api.containr.local',
|
||||||
createdAt: '2026-03-12T10:02:00Z',
|
createdAt: '2026-03-12T10:02:00Z',
|
||||||
updatedAt: '2026-03-31T08:43:00Z',
|
updatedAt: '2026-03-31T08:43:00Z',
|
||||||
},
|
},
|
||||||
@@ -81,9 +85,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'worker',
|
type: 'worker',
|
||||||
status: 'building',
|
status: 'building',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'ghcr.io/containr/worker:2026.03.31',
|
image: 'ghcr.io/containr/worker:edge',
|
||||||
command: './worker',
|
command: './worker',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
|
port: 9100,
|
||||||
createdAt: '2026-03-12T10:05:00Z',
|
createdAt: '2026-03-12T10:05:00Z',
|
||||||
updatedAt: '2026-03-31T08:44:00Z',
|
updatedAt: '2026-03-31T08:44:00Z',
|
||||||
},
|
},
|
||||||
@@ -94,12 +99,27 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'database',
|
type: 'database',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'postgres:15-alpine',
|
image: 'postgres:16',
|
||||||
command: 'postgres',
|
command: 'postgres',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
|
port: 5432,
|
||||||
createdAt: '2026-03-12T10:08:00Z',
|
createdAt: '2026-03-12T10:08:00Z',
|
||||||
updatedAt: '2026-03-31T08:40: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': [
|
'demo-project-growth': [
|
||||||
{
|
{
|
||||||
@@ -109,9 +129,11 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'web',
|
type: 'web',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'ghcr.io/containr/marketing:latest',
|
image: 'nginx:1.27-alpine',
|
||||||
command: 'npm run start',
|
command: 'nginx -g daemon off;',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
|
port: 8080,
|
||||||
|
domain: 'growth.containr.local',
|
||||||
createdAt: '2026-03-03T09:00:00Z',
|
createdAt: '2026-03-03T09:00:00Z',
|
||||||
updatedAt: '2026-03-30T12:10:00Z',
|
updatedAt: '2026-03-30T12:10:00Z',
|
||||||
},
|
},
|
||||||
@@ -122,9 +144,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'web',
|
type: 'web',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'ghcr.io/containr/campaign-api:latest',
|
image: 'node:20-alpine',
|
||||||
command: './api',
|
command: './api',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
|
port: 3000,
|
||||||
createdAt: '2026-03-03T09:04:00Z',
|
createdAt: '2026-03-03T09:04:00Z',
|
||||||
updatedAt: '2026-03-30T12:08:00Z',
|
updatedAt: '2026-03-30T12:08:00Z',
|
||||||
},
|
},
|
||||||
@@ -135,9 +158,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'database',
|
type: 'database',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'redis:7-alpine',
|
image: 'dragonfly:1.25',
|
||||||
command: 'redis-server',
|
command: 'redis-server',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
|
port: 6379,
|
||||||
createdAt: '2026-03-03T09:07:00Z',
|
createdAt: '2026-03-03T09:07:00Z',
|
||||||
updatedAt: '2026-03-30T12:07:00Z',
|
updatedAt: '2026-03-30T12:07:00Z',
|
||||||
},
|
},
|
||||||
@@ -150,9 +174,10 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'web',
|
type: 'web',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'ghcr.io/containr/inference-api:latest',
|
image: 'python:3.12-slim',
|
||||||
command: './serve',
|
command: './serve',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
|
port: 8000,
|
||||||
createdAt: '2026-02-18T08:00:00Z',
|
createdAt: '2026-02-18T08:00:00Z',
|
||||||
updatedAt: '2026-03-29T18:00:00Z',
|
updatedAt: '2026-03-29T18:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -163,7 +188,7 @@ export const demoServicesByProject: Record<string, ServiceEntity[]> = {
|
|||||||
type: 'worker',
|
type: 'worker',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
environment: 'production',
|
environment: 'production',
|
||||||
image: 'ghcr.io/containr/evaluator:latest',
|
image: 'python:3.12-slim',
|
||||||
command: './run-jobs',
|
command: './run-jobs',
|
||||||
gitBranch: 'main',
|
gitBranch: 'main',
|
||||||
createdAt: '2026-02-18T08:03:00Z',
|
createdAt: '2026-02-18T08:03:00Z',
|
||||||
@@ -195,6 +220,7 @@ export const demoVariablesByProject: Record<string, Record<string, ServiceVariab
|
|||||||
] satisfies ServiceVariable[]
|
] satisfies ServiceVariable[]
|
||||||
),
|
),
|
||||||
'demo-svc-postgres': [],
|
'demo-svc-postgres': [],
|
||||||
|
'demo-svc-redis': [],
|
||||||
},
|
},
|
||||||
'demo-project-growth': {
|
'demo-project-growth': {
|
||||||
'demo-growth-site': (
|
'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