chore(v5): archive V5 implementation and remove runtime integration

Move V5 source, migrations, UI, scripts, and tests into documentation, then remove V5 routes, models, jobs, configuration, dependencies, and application hooks.
This commit is contained in:
Andras Bacsai committed 2026-08-15 19:13:30 +02:00
1 parent 1440d35750
commit 76030a30d4
237 files changed
+288 -7079

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Archived V5 UI Prototype
This directory preserves the previous V5 UI source for design and implementation
reference only. The `.txt` suffixes keep the files outside frontend build and
source-discovery paths.
Do not import or serve these files. V5 will use a different implementation.
+182
View File
@@ -0,0 +1,182 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@source '../../js/v5/**/*.{js,jsx,ts,tsx}';
@source '../../../storage/framework/views/*.php';
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--font-sans: 'Geist Variable', sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-warning: var(--warning);
--color-coollabs-50: #f5f0ff;
--color-coollabs: #6b16ed;
--color-coollabs-100: #7317ff;
--color-coollabs-200: #5a12c7;
--color-coollabs-300: #4a0fa3;
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--default-ring-color: var(--ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--warning: #fcd452;
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: var(--warning);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: #101010;
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--warning: #fcd452;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: var(--warning);
--chart-1: oklch(0.871 0.006 286.286);
--chart-2: oklch(0.552 0.016 285.938);
--chart-3: oklch(0.442 0.017 285.786);
--chart-4: oklch(0.37 0.013 285.805);
--chart-5: oklch(0.274 0.006 286.033);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
@apply border-border outline-ring/50;
--tw-ring-offset-color: var(--background);
}
:where(a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
@apply outline-none ring-2 dark:ring-1 ring-ring ring-offset-2;
}
html,
body,
#v5-app {
min-height: 100dvh;
}
html {
@apply font-sans;
}
@media (max-width: 767px) {
:root input,
:root textarea,
:root select {
font-size: 16px !important;
}
}
body {
@apply bg-background text-foreground;
min-height: 100dvh;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,806 @@
import { Head } from '@inertiajs/react';
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent } from 'react';
import { AppNavbar } from '@/components/app-navbar';
import { ApplicationCard } from '@/components/canvas/application-card';
import { ApplicationInspectorSheet } from '@/components/canvas/application-inspector-sheet';
import { CaddyIngressCard } from '@/components/canvas/caddy-ingress-card';
import { CanvasNotice } from '@/components/canvas/canvas-notice';
import { CanvasToolbar } from '@/components/canvas/canvas-toolbar';
import { ConnectionLines } from '@/components/canvas/connection-lines';
import { ConnectionPortsEditor } from '@/components/canvas/connection-ports-editor';
import { IngressDialog } from '@/components/canvas/ingress-dialog';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { TooltipProvider } from '@/components/ui/tooltip';
import { canvasRequest } from '@/lib/canvas-api';
import {
connectorPoint,
resolveApplicationPosition,
resolveIngressPosition,
settleCanvasResources,
type ConnectionEndpoint,
type ConnectorSide,
type DraftConnection,
} from '@/lib/canvas-geometry';
import { runOptimisticUpdate } from '@/lib/optimistic';
import { useApplicationIngress } from '@/lib/use-application-ingress';
import { useCanvasConnections } from '@/lib/use-canvas-connections';
import { useCanvasResourceMerge } from '@/lib/use-canvas-resource-merge';
import { useCanvasViewport, type Viewport } from '@/lib/use-canvas-viewport';
import { usePendingIds } from '@/lib/use-pending-ids';
import type { V5Application, V5CaddyIngress, V5DashboardProps } from '@/types';
const DEFAULT_NGINX_IMAGE = 'docker.io/library/nginx:alpine';
type CanvasPosition = {
canvasX: number;
canvasY: number;
};
type DeleteApplicationResponse = {
message?: string;
can_delete_locally?: boolean;
};
type PendingLocalDelete = {
application: V5Application;
message: string;
};
type PointerState =
| {
type: 'pan';
pointerId: number;
startClientX: number;
startClientY: number;
startViewport: Viewport;
}
| {
type: 'app';
pointerId: number;
applicationId: string;
startClientX: number;
startClientY: number;
startX: number;
startY: number;
}
| {
type: 'ingress';
pointerId: number;
ingressId: string;
startClientX: number;
startClientY: number;
startX: number;
startY: number;
}
| {
type: 'connection';
pointerId: number;
from: ConnectionEndpoint;
};
export default function Dashboard({
flux,
currentTeam = null,
applications: initialApplications = [],
caddyIngresses = [],
resourceConnections: initialResourceConnections = [],
nginxServers = [],
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
selectedApplicationUuid = null,
}: V5DashboardProps) {
const [applications, setApplications] = useState<V5Application[]>(initialApplications);
const [ingresses, setIngresses] = useState<V5CaddyIngress[]>(caddyIngresses);
const [selectedApplicationId, setSelectedApplicationId] = useState<string | null>(null);
const [selectedInspectorApplicationId, setSelectedInspectorApplicationId] = useState<string | null>(null);
const [draftConnection, setDraftConnection] = useState<DraftConnection | null>(null);
const [pointerState, setPointerState] = useState<PointerState | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [selectedNginxServerId, setSelectedNginxServerId] = useState<string>(nginxServers[0]?.id ?? '');
const [nginxImage, setNginxImage] = useState<string>(DEFAULT_NGINX_IMAGE);
const [isRefreshing, setIsRefreshing] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [pendingLocalDelete, setPendingLocalDelete] = useState<PendingLocalDelete | null>(null);
const deletingApplications = usePendingIds<string>();
const applicationsRef = useRef(applications);
applicationsRef.current = applications;
/**
* Cards that are mid-drag or whose position PATCH has not landed yet keep
* their local canvasX/canvasY when a websocket merge arrives.
*/
const locallyPositionedApplicationIdsRef = useRef<Set<string>>(new Set());
const locallyPositionedIngressIdsRef = useRef<Set<string>>(new Set());
const { canvasRef, viewport, setViewport, zoomCanvas, handleCanvasWheel, centerOnCanvasNodes, canvasPointFromPointer } =
useCanvasViewport();
const {
connections,
selectedConnectionId,
setSelectedConnectionId,
connectionPortInput,
setConnectionPortDraft,
resetConnections,
connectionExists,
persistNewConnection,
deleteConnection,
updateConnectionDirection,
addConnectionPort,
removeConnectionPort,
removeConnectionsForApplication,
} = useCanvasConnections(initialResourceConnections, setNotice);
const applyApplicationUpdate = useCallback((application: V5Application): void => {
setApplications((currentApplications) =>
currentApplications.map((candidate) => (candidate.id === application.id ? application : candidate)),
);
}, []);
const {
ingressModal,
closeIngressModal,
setIngressModalDomains,
setIngressModalInternalPort,
submitApplicationIngress,
toggleApplicationIngress,
savingIngressApplications,
} = useApplicationIngress({ notify: setNotice, onApplicationUpdated: applyApplicationUpdate });
const hasCanvasNodes = applications.length > 0 || ingresses.length > 0;
const statusCounts = useMemo(
() => ({
running: applications.filter((application) => application.effectiveStatus === 'running').length,
failed: applications.filter((application) => application.effectiveStatus === 'failed').length,
unknown: applications.filter((application) => application.effectiveStatus === 'unknown').length,
}),
[applications],
);
const selectedInspectorApplication = useMemo(
() => applications.find((application) => application.id === selectedInspectorApplicationId) ?? null,
[applications, selectedInspectorApplicationId],
);
const selectedConnection = useMemo(
() => connections.find((connection) => connection.id === selectedConnectionId) ?? null,
[connections, selectedConnectionId],
);
useEffect(() => {
const settledResources = settleCanvasResources(initialApplications, caddyIngresses);
setApplications(settledResources.applications);
setIngresses(settledResources.ingresses);
resetConnections(initialResourceConnections);
setSelectedNginxServerId((currentServerId) => currentServerId || nginxServers[0]?.id || '');
const linkedApplicationExists = settledResources.applications.some((application) => application.id === selectedApplicationUuid);
setSelectedApplicationId(linkedApplicationExists ? selectedApplicationUuid : null);
setSelectedInspectorApplicationId(linkedApplicationExists ? selectedApplicationUuid : null);
centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
initialApplications,
caddyIngresses,
initialResourceConnections,
nginxServers[0]?.id,
selectedProjectUuid,
selectedEnvironmentUuid,
selectedApplicationUuid,
]);
useCanvasResourceMerge({
teamId: currentTeam?.id ?? null,
selectedProjectUuid,
selectedEnvironmentUuid,
setApplications,
setIngresses,
locallyPositionedApplicationIds: locallyPositionedApplicationIdsRef,
locallyPositionedIngressIds: locallyPositionedIngressIdsRef,
});
const persistApplicationPosition = useCallback(async (application: V5Application, previousPosition: CanvasPosition): Promise<void> => {
await runOptimisticUpdate({
rollback: () =>
setApplications((currentApplications) =>
currentApplications.map((candidate) => (candidate.id === application.id ? { ...candidate, ...previousPosition } : candidate)),
),
request: async () => {
const response = await canvasRequest(`/v5/applications/${application.id}/position`, {
method: 'PATCH',
body: {
canvas_x: application.canvasX,
canvas_y: application.canvasY,
},
});
return response.ok ? { ok: true, payload: undefined } : { ok: false };
},
fallbackErrorMessage: 'Could not save the card position.',
notify: setNotice,
onSettled: () => locallyPositionedApplicationIdsRef.current.delete(application.id),
});
}, []);
const persistCaddyIngressPosition = useCallback(async (ingress: V5CaddyIngress, previousPosition: CanvasPosition): Promise<void> => {
await runOptimisticUpdate({
rollback: () =>
setIngresses((currentIngresses) =>
currentIngresses.map((candidate) => (candidate.id === ingress.id ? { ...candidate, ...previousPosition } : candidate)),
),
request: async () => {
const response = await canvasRequest(`/v5/caddy-ingresses/${ingress.id}/position`, {
method: 'PATCH',
body: {
canvas_x: ingress.canvasX,
canvas_y: ingress.canvasY,
},
});
return response.ok ? { ok: true, payload: undefined } : { ok: false };
},
fallbackErrorMessage: 'Could not save the card position.',
notify: setNotice,
onSettled: () => locallyPositionedIngressIdsRef.current.delete(ingress.id),
});
}, []);
const removeApplication = useCallback(
async (application: V5Application, deleteLocally = false): Promise<void> => {
setNotice(null);
deletingApplications.start(application.id);
try {
const response = await canvasRequest(`/v5/applications/${application.id}`, {
method: 'DELETE',
body: deleteLocally ? { delete_locally: true } : undefined,
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as DeleteApplicationResponse;
const message = payload.message ?? 'Could not delete application.';
setNotice(message);
if (payload.can_delete_locally && !deleteLocally) {
setPendingLocalDelete({ application, message });
}
return;
}
setApplications((currentApplications) => currentApplications.filter((candidate) => candidate.id !== application.id));
removeConnectionsForApplication(application.id);
} catch (error) {
setNotice(error instanceof Error ? error.message : 'Could not delete application.');
} finally {
deletingApplications.finish(application.id);
}
},
[deletingApplications.start, deletingApplications.finish, removeConnectionsForApplication],
);
const deleteApplication = useCallback(
(application: V5Application): void => {
void removeApplication(application);
},
[removeApplication],
);
const addNginx = useCallback(async (): Promise<void> => {
setIsCreating(true);
setNotice(null);
try {
const response = await canvasRequest('/v5/applications/nginx', {
method: 'POST',
body: {
server_uuid: selectedNginxServerId || null,
image: nginxImage.trim() || DEFAULT_NGINX_IMAGE,
},
});
const payload = (await response.json()) as { application?: V5Application; message?: string };
if (!response.ok || !payload.application) {
setNotice(payload.message ?? 'Could not deploy nginx.');
return;
}
const settledResources = settleCanvasResources([...applicationsRef.current, payload.application], ingresses);
const settledApplication = settledResources.applications.find(
(application) => application.id === payload.application?.id,
);
setApplications(settledResources.applications);
setIngresses(settledResources.ingresses);
centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);
if (
settledApplication &&
(settledApplication.canvasX !== payload.application.canvasX ||
settledApplication.canvasY !== payload.application.canvasY)
) {
locallyPositionedApplicationIdsRef.current.add(settledApplication.id);
void persistApplicationPosition(settledApplication, {
canvasX: payload.application.canvasX,
canvasY: payload.application.canvasY,
});
}
} catch (error) {
setNotice(error instanceof Error ? error.message : 'Could not deploy nginx.');
} finally {
setIsCreating(false);
}
}, [selectedNginxServerId, nginxImage, ingresses, centerOnCanvasNodes, persistApplicationPosition]);
const refreshApplications = useCallback(async (): Promise<void> => {
setIsRefreshing(true);
setNotice(null);
try {
const response = await canvasRequest('/v5/applications/refresh', { method: 'POST' });
const payload = (await response.json()) as {
applications?: V5Application[];
errors?: string[];
message?: string;
};
if (payload.applications) {
const settledResources = settleCanvasResources(payload.applications, ingresses);
setApplications(settledResources.applications);
setIngresses(settledResources.ingresses);
}
if (!response.ok) {
setNotice(payload.message ?? 'Could not refresh application state.');
} else if (payload.errors && payload.errors.length > 0) {
setNotice(payload.errors[0] ?? 'Could not refresh all application state.');
}
} catch (error) {
setNotice(error instanceof Error ? error.message : 'Could not refresh application state.');
} finally {
setIsRefreshing(false);
}
}, [ingresses]);
const startApplicationDrag = useCallback(
(event: PointerEvent<HTMLDivElement>, application: V5Application): void => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setSelectedConnectionId(null);
setSelectedApplicationId(application.id);
locallyPositionedApplicationIdsRef.current.add(application.id);
setPointerState({
type: 'app',
pointerId: event.pointerId,
applicationId: application.id,
startClientX: event.clientX,
startClientY: event.clientY,
startX: application.canvasX,
startY: application.canvasY,
});
},
[setSelectedConnectionId],
);
const startIngressDrag = useCallback((event: PointerEvent<HTMLDivElement>, ingress: V5CaddyIngress): void => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
locallyPositionedIngressIdsRef.current.add(ingress.id);
setPointerState({
type: 'ingress',
pointerId: event.pointerId,
ingressId: ingress.id,
startClientX: event.clientX,
startClientY: event.clientY,
startX: ingress.canvasX,
startY: ingress.canvasY,
});
}, []);
const startConnectionDrag = useCallback(
(event: PointerEvent<HTMLButtonElement>, applicationId: string, side: ConnectorSide): void => {
event.stopPropagation();
const from = { applicationId, side };
const fromApplication = applicationsRef.current.find((candidate) => candidate.id === applicationId);
const startPoint = fromApplication ? connectorPoint(fromApplication, side) : canvasPointFromPointer(event);
setDraftConnection({
from,
toX: startPoint.x,
toY: startPoint.y,
});
setPointerState({
type: 'connection',
pointerId: event.pointerId,
from,
});
},
[canvasPointFromPointer],
);
const selectConnection = useCallback(
(event: MouseEvent<SVGLineElement>, connectionId: string): void => {
event.stopPropagation();
setSelectedConnectionId(connectionId);
setSelectedApplicationId(null);
},
[setSelectedConnectionId],
);
const openApplicationInspector = useCallback((event: MouseEvent<HTMLElement>, application: V5Application): void => {
event.stopPropagation();
setSelectedApplicationId(application.id);
setSelectedInspectorApplicationId(application.id);
}, []);
function startPan(event: PointerEvent<HTMLDivElement>): void {
if (event.target !== event.currentTarget) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
setPointerState({
type: 'pan',
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startViewport: viewport,
});
}
function clearCanvasSelection(event: MouseEvent<HTMLDivElement>): void {
if (event.target !== event.currentTarget) {
return;
}
setSelectedConnectionId(null);
setSelectedApplicationId(null);
}
function connectionTargetFromPointer(event: PointerEvent<HTMLDivElement>): HTMLElement | null {
const pointerTarget = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
// Touch browsers may keep pointer-captured mobile drags targeted at the origin connector.
return pointerTarget?.closest<HTMLElement>('[data-application-card]') ?? null;
}
function movePointer(event: PointerEvent<HTMLDivElement>): void {
if (!pointerState || pointerState.pointerId !== event.pointerId) {
return;
}
if (pointerState.type === 'connection') {
const point = canvasPointFromPointer(event);
setDraftConnection({
from: pointerState.from,
toX: point.x,
toY: point.y,
});
return;
}
const deltaX = event.clientX - pointerState.startClientX;
const deltaY = event.clientY - pointerState.startClientY;
if (pointerState.type === 'pan') {
setViewport({
x: pointerState.startViewport.x + deltaX,
y: pointerState.startViewport.y + deltaY,
zoom: pointerState.startViewport.zoom,
});
return;
}
if (pointerState.type === 'app') {
setApplications((currentApplications) =>
currentApplications.map((application) =>
application.id === pointerState.applicationId
? {
...application,
canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom),
canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom),
}
: application,
),
);
return;
}
setIngresses((currentIngresses) =>
currentIngresses.map((ingress) =>
ingress.id === pointerState.ingressId
? {
...ingress,
canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom),
canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom),
}
: ingress,
),
);
}
function stopPointer(event: PointerEvent<HTMLDivElement>): void {
if (!pointerState || pointerState.pointerId !== event.pointerId) {
return;
}
if (pointerState.type === 'connection') {
const target = connectionTargetFromPointer(event);
const targetApplicationId = target?.dataset.applicationId;
if (
targetApplicationId &&
targetApplicationId !== pointerState.from.applicationId &&
!connectionExists(pointerState.from.applicationId, targetApplicationId)
) {
void persistNewConnection(pointerState.from.applicationId, targetApplicationId);
}
setDraftConnection(null);
setPointerState(null);
return;
}
const deltaX = event.clientX - pointerState.startClientX;
const deltaY = event.clientY - pointerState.startClientY;
if (pointerState.type === 'app') {
const application = applications.find((candidate) => candidate.id === pointerState.applicationId);
if (application) {
const updatedApplication = resolveApplicationPosition(
{
...application,
canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom),
canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom),
},
applications,
ingresses,
);
setApplications((currentApplications) =>
currentApplications.map((candidate) => (candidate.id === updatedApplication.id ? updatedApplication : candidate)),
);
void persistApplicationPosition(updatedApplication, {
canvasX: pointerState.startX,
canvasY: pointerState.startY,
});
}
}
if (pointerState.type === 'ingress') {
const ingress = ingresses.find((candidate) => candidate.id === pointerState.ingressId);
if (ingress) {
const updatedIngress = resolveIngressPosition(
{
...ingress,
canvasX: Math.round(pointerState.startX + deltaX / viewport.zoom),
canvasY: Math.round(pointerState.startY + deltaY / viewport.zoom),
},
applications,
ingresses,
);
setIngresses((currentIngresses) =>
currentIngresses.map((candidate) => (candidate.id === updatedIngress.id ? updatedIngress : candidate)),
);
void persistCaddyIngressPosition(updatedIngress, {
canvasX: pointerState.startX,
canvasY: pointerState.startY,
});
}
}
setPointerState(null);
}
const deployNginx = useCallback((): void => {
void addNginx();
}, [addNginx]);
const refreshCanvas = useCallback((): void => {
void refreshApplications();
}, [refreshApplications]);
const centerCanvas = useCallback((): void => {
centerOnCanvasNodes(applications, ingresses);
}, [applications, ingresses, centerOnCanvasNodes]);
const zoomIn = useCallback((): void => zoomCanvas(1), [zoomCanvas]);
const zoomOut = useCallback((): void => zoomCanvas(-1), [zoomCanvas]);
const dismissNotice = useCallback((): void => setNotice(null), []);
const closeInspector = useCallback((): void => setSelectedInspectorApplicationId(null), []);
const submitIngress = useCallback((): void => {
void submitApplicationIngress();
}, [submitApplicationIngress]);
return (
<TooltipProvider>
<Head title="Dashboard" />
<div className="h-dvh overflow-hidden bg-background text-foreground">
<AppNavbar
flux={flux}
projects={projects}
selectedProjectUuid={selectedProjectUuid}
selectedEnvironmentUuid={selectedEnvironmentUuid}
/>
<main className="relative h-full min-h-0 overflow-hidden pt-16">
<CanvasToolbar
nginxServers={nginxServers}
selectedNginxServerId={selectedNginxServerId}
onSelectNginxServer={setSelectedNginxServerId}
nginxImage={nginxImage}
onNginxImageChange={setNginxImage}
isCreating={isCreating}
onDeploy={deployNginx}
onCenter={centerCanvas}
zoom={viewport.zoom}
onZoomIn={zoomIn}
onZoomOut={zoomOut}
isRefreshing={isRefreshing}
onRefresh={refreshCanvas}
applicationsCount={applications.length}
statusCounts={statusCounts}
/>
{notice && <CanvasNotice message={notice} onDismiss={dismissNotice} />}
<div
ref={canvasRef}
className="relative size-full touch-none overflow-hidden bg-[radial-gradient(circle_at_1px_1px,var(--border)_1px,transparent_0)] [background-size:32px_32px]"
onPointerDown={startPan}
onClick={clearCanvasSelection}
onPointerMove={movePointer}
onPointerUp={stopPointer}
onPointerCancel={stopPointer}
onWheel={handleCanvasWheel}
>
{!hasCanvasNodes && (
<section className="pointer-events-none absolute left-1/2 top-1/2 flex w-[min(32rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card/90 px-8 py-16 text-center shadow-sm">
<p className="text-sm font-medium text-foreground">No applications on this canvas yet.</p>
<p className="max-w-md text-sm text-muted-foreground">
Click Deploy to deploy a test container on one of your v5 servers.
</p>
</section>
)}
<div
className="absolute left-0 top-0"
style={{
transform: `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.zoom})`,
transformOrigin: '0 0',
}}
>
{ingresses.map((ingress) => (
<CaddyIngressCard key={`caddy-ingress-${ingress.id}`} ingress={ingress} onDragStart={startIngressDrag} />
))}
<ConnectionLines
connections={connections}
applications={applications}
selectedConnectionId={selectedConnectionId}
draftConnection={draftConnection}
onSelectConnection={selectConnection}
/>
{selectedConnection && (
<ConnectionPortsEditor
connection={selectedConnection}
applications={applications}
portInput={connectionPortInput[selectedConnection.id] ?? ''}
onPortInputChange={setConnectionPortDraft}
onUpdateDirection={updateConnectionDirection}
onAddPort={addConnectionPort}
onRemovePort={removeConnectionPort}
onDelete={deleteConnection}
/>
)}
{applications.map((application) => (
<ApplicationCard
key={application.id}
application={application}
isSelected={selectedApplicationId === application.id}
isDeleting={deletingApplications.pendingIds.has(application.id)}
isIngressSaving={savingIngressApplications.pendingIds.has(application.id)}
onDragStart={startApplicationDrag}
onOpenInspector={openApplicationInspector}
onDelete={deleteApplication}
onToggleIngress={toggleApplicationIngress}
onConnectorPointerDown={startConnectionDrag}
/>
))}
</div>
</div>
</main>
</div>
<ApplicationInspectorSheet
application={selectedInspectorApplication}
isIngressSaving={
selectedInspectorApplication !== null && savingIngressApplications.pendingIds.has(selectedInspectorApplication.id)
}
onToggleIngress={toggleApplicationIngress}
onClose={closeInspector}
/>
<Dialog open={pendingLocalDelete !== null} onOpenChange={(open) => !open && setPendingLocalDelete(null)}>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Delete from Coolify only?</DialogTitle>
<DialogDescription>
Coolify could not reach the server to clean up containers, volumes, networks, or ingress config.
</DialogDescription>
</DialogHeader>
{pendingLocalDelete && (
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
<p className="break-words rounded-md border border-border bg-muted/40 p-3 font-mono text-xs">
{pendingLocalDelete.message}
</p>
<p>
Deleting locally removes {pendingLocalDelete.application.name} from Coolify, but orphaned resources may remain on the unreachable server.
</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPendingLocalDelete(null)}>
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
const application = pendingLocalDelete?.application;
setPendingLocalDelete(null);
if (application) {
void removeApplication(application);
}
}}
>
Retry
</Button>
<Button
type="button"
variant="destructive"
onClick={() => {
const application = pendingLocalDelete?.application;
setPendingLocalDelete(null);
if (application) {
void removeApplication(application, true);
}
}}
>
Delete from Coolify only
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ingressModal && (
<IngressDialog
modal={ingressModal}
isSaving={savingIngressApplications.pendingIds.has(ingressModal.application.id)}
onDomainsChange={setIngressModalDomains}
onInternalPortChange={setIngressModalInternalPort}
onSubmit={submitIngress}
onClose={closeIngressModal}
/>
)}
</TooltipProvider>
);
}
@@ -0,0 +1,147 @@
import { Head } from '@inertiajs/react';
import { useCallback, useEffect, useState } from 'react';
import { AppNavbar } from '@/components/app-navbar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { csrfToken } from '@/lib/csrf';
import { useTeamChannel } from '@/lib/use-team-channel';
import type { V5DashboardProps } from '@/types';
type RealtimeTestProps = V5DashboardProps & {
currentTeam: {
id: number;
} | null;
};
type RealtimeTestEvent = {
message: string;
teamId: number;
sentAt: string;
};
function formatLogPayload(payload: unknown): string {
if (typeof payload === 'string') {
return payload;
}
return JSON.stringify(payload, null, 2);
}
export default function RealtimeTest({ currentTeam, flux, projects = [], selectedProjectUuid = null, selectedEnvironmentUuid = null }: RealtimeTestProps) {
const [message, setMessage] = useState('Hello from v5 realtime test');
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const addLog = useCallback((label: string, payload?: unknown): void => {
const timestamp = new Date().toLocaleTimeString();
setLogs((currentLogs) => [
`[${timestamp}] ${label}${payload === undefined ? '' : `\n${formatLogPayload(payload)}`}`,
...currentLogs,
]);
}, []);
useEffect(() => {
if (!currentTeam) {
addLog('No current team was provided to the page.');
}
}, [currentTeam, addLog]);
useTeamChannel(
currentTeam?.id ?? null,
'.v5.realtime.test',
(payload) => {
const event = payload as RealtimeTestEvent;
addLog('Received .v5.realtime.test', event);
},
{ onDebug: addLog, onError: addLog },
);
async function broadcastTestEvent(): Promise<void> {
setIsBroadcasting(true);
addLog('Sending POST /v5/realtime-test');
const response = await fetch('/v5/realtime-test', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify({ message }),
});
const payload = await response.json().catch(() => null);
addLog(`POST /v5/realtime-test responded ${response.status}`, payload);
setIsBroadcasting(false);
}
return (
<>
<Head title="Realtime test" />
<div className="min-h-dvh bg-background text-foreground">
<AppNavbar
flux={flux}
clusters={[]}
projects={projects}
selectedProjectUuid={selectedProjectUuid}
selectedEnvironmentUuid={selectedEnvironmentUuid}
/>
<main className="mx-auto flex max-w-5xl flex-col gap-6 px-4 pt-20 pb-8 lg:px-6">
<section className="rounded-lg border border-border bg-card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">RealtimeTest</p>
<h1 className="mt-1 text-2xl font-semibold text-foreground">v5 realtime websocket test</h1>
<p className="mt-2 text-sm text-muted-foreground">
Opens a private team channel subscription and broadcasts a manual backend event named{' '}
<code className="rounded bg-muted px-1 py-0.5">v5.realtime.test</code>.
</p>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-lg border border-border bg-card p-4">
<h2 className="text-sm font-semibold text-foreground">Runtime state</h2>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Team ID</dt>
<dd className="font-medium text-foreground">{currentTeam?.id ?? 'Missing'}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Echo</dt>
<dd className="font-medium text-foreground">{typeof window !== 'undefined' && window.Echo ? 'Available' : 'Missing'}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Channel</dt>
<dd className="font-medium text-foreground">{currentTeam ? `private-team.${currentTeam.id}` : 'Missing'}</dd>
</div>
</dl>
</div>
<div className="rounded-lg border border-border bg-card p-4 lg:col-span-2">
<h2 className="text-sm font-semibold text-foreground">Send test event</h2>
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
<Input value={message} onChange={(event) => setMessage(event.target.value)} />
<Button type="button" variant="coolify" disabled={isBroadcasting} onClick={() => void broadcastTestEvent()}>
{isBroadcasting ? 'Broadcasting...' : 'Broadcast event'}
</Button>
</div>
</div>
</section>
<section className="rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-foreground">Event log</h2>
<Button type="button" variant="outline" size="sm" onClick={() => setLogs([])}>
Clear
</Button>
</div>
<pre className="mt-3 min-h-64 overflow-auto whitespace-pre-wrap rounded-md bg-background p-4 text-xs text-muted-foreground">
{logs.length === 0 ? 'No logs yet.' : logs.join('\n\n')}
</pre>
</section>
</main>
</div>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { createInertiaApp } from '@inertiajs/react';
import '../../css/v5/app.css';
createInertiaApp({
id: 'v5-app',
pages: {
path: './Pages',
extension: '.tsx',
},
strictMode: true,
progress: {
delay: 10,
color: '#fcd452',
showSpinner: false,
},
});
@@ -0,0 +1,243 @@
import { Link, router, usePage } from '@inertiajs/react';
import { useEffect, useMemo, useState } from 'react';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { csrfToken } from '@/lib/csrf';
import { cn } from '@/lib/utils';
import type { SelectItemOption, V5DashboardProps, V5Project } from '@/types';
async function persistSelection(projectUuid: string, environmentUuid: string): Promise<boolean> {
try {
const response = await fetch('/v5/selection', {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken(),
},
body: JSON.stringify({
project_uuid: projectUuid,
environment_uuid: environmentUuid,
}),
});
if (!response.ok) {
console.error(`Could not persist the project/environment selection (HTTP ${response.status}).`);
}
return response.ok;
} catch (error) {
console.error('Could not persist the project/environment selection.', error);
return false;
}
}
function refreshCurrentPageSelection(): void {
router.reload({
only: [
'applications',
'caddyIngresses',
'resourceConnections',
'nginxServers',
'selectedProjectUuid',
'selectedEnvironmentUuid',
],
});
}
type AppNavbarProps = V5DashboardProps;
export function AppNavbar({
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
}: AppNavbarProps) {
const { url } = usePage();
const firstProject = projects[0] ?? null;
const [projectUuid, setProjectUuid] = useState<string>(selectedProjectUuid ?? firstProject?.uuid ?? '');
const selectedProject = useMemo<V5Project | null>(
() => projects.find((project) => project.uuid === projectUuid) ?? firstProject,
[firstProject, projectUuid, projects],
);
const firstEnvironment = selectedProject?.environments?.[0] ?? null;
const [environmentUuid, setEnvironmentUuid] = useState<string>(selectedEnvironmentUuid ?? firstEnvironment?.uuid ?? '');
const selectedEnvironment = useMemo(
() => selectedProject?.environments?.find((environment) => environment.uuid === environmentUuid) ?? firstEnvironment,
[environmentUuid, firstEnvironment, selectedProject],
);
useEffect(() => {
setProjectUuid(selectedProjectUuid ?? firstProject?.uuid ?? '');
}, [firstProject?.uuid, selectedProjectUuid]);
useEffect(() => {
setEnvironmentUuid(selectedEnvironmentUuid ?? firstEnvironment?.uuid ?? '');
}, [firstEnvironment?.uuid, selectedEnvironmentUuid]);
function selectProject(nextProjectUuid: string | null): void {
if (nextProjectUuid === null) {
return;
}
const nextProject = projects.find((project) => project.uuid === nextProjectUuid);
const nextEnvironmentUuid = nextProject?.environments?.[0]?.uuid ?? '';
const previousProjectUuid = projectUuid;
const previousEnvironmentUuid = environmentUuid;
setProjectUuid(nextProjectUuid);
setEnvironmentUuid(nextEnvironmentUuid);
void persistSelection(nextProjectUuid, nextEnvironmentUuid).then((persisted) => {
if (persisted) {
refreshCurrentPageSelection();
return;
}
setProjectUuid(previousProjectUuid);
setEnvironmentUuid(previousEnvironmentUuid);
});
}
function selectEnvironment(nextEnvironmentUuid: string | null): void {
if (nextEnvironmentUuid === null) {
return;
}
const previousEnvironmentUuid = environmentUuid;
setEnvironmentUuid(nextEnvironmentUuid);
void persistSelection(projectUuid, nextEnvironmentUuid).then((persisted) => {
if (persisted) {
refreshCurrentPageSelection();
return;
}
setEnvironmentUuid(previousEnvironmentUuid);
});
}
const projectItems: SelectItemOption[] = projects.map((project) => ({
label: project.name,
value: project.uuid,
}));
const environmentItems: SelectItemOption[] = (selectedProject?.environments ?? []).map((environment) => ({
label: environment.name,
value: environment.uuid,
}));
const isClustersPage = url.startsWith('/v5/clusters');
return (
<header className="fixed inset-x-0 top-0 z-40 border-b border-border bg-background">
<nav className="relative flex h-16 items-center gap-3 px-4 sm:px-6" aria-label="Main navigation">
<Link
href="/v5"
className="flex shrink-0 items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label="Coolify dashboard"
>
<img src="/coolify-logo.svg" alt="Coolify" className="size-6" />
</Link>
<div className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center gap-1 md:static md:flex-1 md:translate-x-0 md:justify-start md:gap-2">
<Select
items={projectItems}
value={selectedProject?.uuid ?? ''}
onValueChange={selectProject}
disabled={projects.length === 0}
>
<SelectTrigger aria-label="Select a project" variant="ghost" className="max-w-[38vw] md:max-w-[10rem]">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent position="popper" align="start" sideOffset={4}>
<SelectGroup>
{projects.map((project) => (
<SelectItem key={project.uuid} value={project.uuid}>
{project.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="text-muted-foreground">/</span>
<Select
items={environmentItems}
value={selectedEnvironment?.uuid ?? ''}
onValueChange={selectEnvironment}
disabled={!selectedProject || (selectedProject.environments ?? []).length === 0}
>
<SelectTrigger aria-label="Select an environment" variant="ghost" className="max-w-[30vw] md:max-w-[10rem]">
<SelectValue placeholder="Select an environment" />
</SelectTrigger>
<SelectContent position="popper" align="start" sideOffset={4}>
<SelectGroup>
{(selectedProject?.environments ?? []).map((environment) => (
<SelectItem key={environment.uuid} value={environment.uuid}>
{environment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="ml-auto flex items-center gap-3">
<Link
href="/v5/clusters"
className="hidden rounded-md px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:inline-flex"
>
Clusters
</Link>
<Sheet>
<SheetTrigger
className="inline-flex rounded-md p-2 text-warning transition-colors hover:bg-muted hover:text-warning md:hidden"
aria-label="Open mobile menu"
>
<svg xmlns="http://www.w3.org/2000/svg" className="size-6" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</SheetTrigger>
<SheetContent side="right" className="w-72 max-w-[85vw] bg-background">
<SheetHeader>
<SheetTitle>Coolify</SheetTitle>
<SheetDescription className="sr-only">Move between Coolify v5 pages.</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-1 px-4" aria-label="Mobile navigation">
<SheetClose
render={<Link href="/v5" />}
className={cn(
'rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-warning',
!isClustersPage && 'bg-accent text-accent-foreground',
)}
>
Dashboard
</SheetClose>
<SheetClose
render={<Link href="/v5/clusters" />}
className={cn(
'rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-warning',
isClustersPage && 'bg-accent text-accent-foreground',
)}
>
Clusters
</SheetClose>
</nav>
</SheetContent>
</Sheet>
</div>
</nav>
</header>
);
}
@@ -0,0 +1,129 @@
import { memo, type MouseEvent, type PointerEvent } from 'react';
import { ApplicationIngressButton } from '@/components/canvas/application-ingress-button';
import { statusBadgeClass } from '@/components/canvas/status-badge';
import { CONNECTOR_SIDES, type ConnectorSide } from '@/lib/canvas-geometry';
import { cn } from '@/lib/utils';
import type { V5Application } from '@/types';
type ApplicationCardProps = {
application: V5Application;
isSelected: boolean;
isDeleting: boolean;
isIngressSaving: boolean;
onDragStart: (event: PointerEvent<HTMLDivElement>, application: V5Application) => void;
onOpenInspector: (event: MouseEvent<HTMLElement>, application: V5Application) => void;
onDelete: (application: V5Application) => void;
onToggleIngress: (application: V5Application) => void;
onConnectorPointerDown: (event: PointerEvent<HTMLButtonElement>, applicationId: string, side: ConnectorSide) => void;
};
export const ApplicationCard = memo(function ApplicationCard({
application,
isSelected,
isDeleting,
isIngressSaving,
onDragStart,
onOpenInspector,
onDelete,
onToggleIngress,
onConnectorPointerDown,
}: ApplicationCardProps) {
return (
<div
data-application-card="application-card"
data-application-id={application.id}
className="group/application absolute h-40 w-80 select-none overflow-visible rounded-xl border border-border bg-card p-4 shadow-xl transition-shadow hover:shadow-2xl"
style={{
transform: `translate3d(${application.canvasX}px, ${application.canvasY}px, 0)`,
}}
onPointerDown={(event) => onDragStart(event, application)}
onDoubleClick={(event) => onOpenInspector(event, application)}
>
{CONNECTOR_SIDES.map((side) => (
<button
key={side}
type="button"
aria-label={`${application.name} ${side} connector`}
data-application-connector="application-connector"
data-application-id={application.id}
data-connector-side={side}
onPointerDown={(event) => onConnectorPointerDown(event, application.id, side)}
className={cn(
'application-connector group/connector absolute z-10 flex size-8 items-center justify-center rounded-full opacity-0 transition group-hover/application:opacity-100 md:size-3',
isSelected && 'opacity-100',
side === 'top' && 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2',
side === 'right' && 'right-0 top-1/2 -translate-y-1/2 translate-x-1/2',
side === 'bottom' && 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2',
side === 'left' && 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2',
)}
>
<span className="size-3 rounded-full border-2 border-card bg-warning shadow ring-2 ring-background transition group-hover/connector:scale-125 group-hover/connector:bg-warning/90" />
</button>
))}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{application.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{application.image}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide',
statusBadgeClass(application.effectiveStatus),
)}
title={application.effectiveStatusMessage ?? undefined}
>
{application.effectiveStatus}
</span>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => onOpenInspector(event, application)}
className="rounded-md border border-border px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-foreground transition hover:bg-muted"
>
Configure
</button>
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onDelete(application);
}}
disabled={isDeleting}
className="rounded-md border border-destructive/40 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-destructive transition hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeleting ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
<dl className="mt-4 grid gap-2 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="shrink-0 text-muted-foreground">Server</dt>
<dd className="truncate text-right font-medium text-foreground">
{application.serverName ?? 'Unknown'}
{!application.isServerReachable && <span className="ml-2 text-destructive">(unreachable)</span>}
</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="shrink-0 text-muted-foreground">Container</dt>
<dd className="truncate text-right font-mono text-[0.6875rem] text-foreground">{application.containerName}</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3">
<dt className="shrink-0 text-muted-foreground">Ingress</dt>
<dd className="flex items-center justify-end gap-2 text-right">
<span className="truncate text-muted-foreground">
{application.ingressEnabled
? `${application.domains.length} domain${application.domains.length === 1 ? '' : 's'} → ${application.internalPort ?? 'no port'}`
: 'Private'}
</span>
<ApplicationIngressButton application={application} isSaving={isIngressSaving} onToggle={onToggleIngress} />
</dd>
</div>
</dl>
</div>
);
});
@@ -0,0 +1,39 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { V5Application } from '@/types';
type ApplicationIngressButtonProps = {
application: V5Application;
isSaving: boolean;
onToggle: (application: V5Application) => void;
};
export function ApplicationIngressButton({ application, isSaving, onToggle }: ApplicationIngressButtonProps) {
const isDisabled = !application.ingressEnabled && !application.serverIngressEnabled;
const button = (
<button
type="button"
onPointerDown={(event) => event.stopPropagation()}
disabled={isDisabled || isSaving}
onClick={(event) => {
event.stopPropagation();
onToggle(application);
}}
className="rounded-sm border border-border px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving ? 'Saving...' : application.ingressEnabled ? 'Disable' : 'Enable'}
</button>
);
if (!isDisabled) {
return button;
}
return (
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>{button}</TooltipTrigger>
<TooltipContent side="top">
<p>You need to enable ingress in server settings first.</p>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,276 @@
import { ApplicationIngressButton } from '@/components/canvas/application-ingress-button';
import { Button } from '@/components/ui/button';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { apiRequest } from '@/lib/api';
import type { V5Application } from '@/types';
import { useCallback, useEffect, useState } from 'react';
type ApplicationInspectorSheetProps = {
application: V5Application | null;
isIngressSaving: boolean;
onToggleIngress: (application: V5Application) => void;
onClose: () => void;
};
type ApplicationLogsResponse = {
status: string;
statusMessage: string | null;
containerId: string | null;
logs: string | null;
logsError: string | null;
};
function ApplicationLogsTab({ application }: { application: V5Application }) {
const [isLoading, setIsLoading] = useState(false);
const [logs, setLogs] = useState<string | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
const fetchLogs = useCallback(async () => {
setIsLoading(true);
setLogsError(null);
try {
const response = await apiRequest(`/v5/applications/${application.id}/logs`, { method: 'GET' });
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = (await response.json()) as ApplicationLogsResponse;
setLogs(data.logs);
setLogsError(data.logsError);
} catch {
setLogs(null);
setLogsError('Could not reach the server to fetch container logs. Try again.');
} finally {
setIsLoading(false);
setHasLoaded(true);
}
}, [application.id]);
// Lazily fetch when the Logs tab first mounts for this app, and reset/refetch
// whenever a different application is inspected.
useEffect(() => {
setLogs(null);
setLogsError(null);
setHasLoaded(false);
void fetchLogs();
}, [fetchLogs]);
const statusMessage = application.effectiveStatusMessage ?? application.statusMessage;
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Deploy status</FieldLabel>
<Input value={application.effectiveStatus} readOnly />
</Field>
<Field>
<FieldLabel>Container</FieldLabel>
<Input value={application.runtimeContainerId ?? 'Not created yet'} readOnly />
</Field>
</div>
<Field>
<FieldLabel>Status message</FieldLabel>
<Textarea value={statusMessage ?? 'No status message yet.'} readOnly className="min-h-16" />
</Field>
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Container logs</FieldLabel>
<Button variant="outline" size="sm" onClick={() => void fetchLogs()} disabled={isLoading}>
{isLoading ? 'Refreshing…' : 'Refresh'}
</Button>
</div>
<Textarea
value={
isLoading && !hasLoaded
? 'Loading container logs…'
: logsError
? logsError
: logs
? logs
: hasLoaded
? 'No container logs yet. The container has not been created. See the status message above for the deploy result.'
: ''
}
readOnly
className="min-h-80 font-mono text-xs"
/>
</Field>
</div>
);
}
export function ApplicationInspectorSheet({ application, isIngressSaving, onToggleIngress, onClose }: ApplicationInspectorSheetProps) {
return (
<Sheet
open={application !== null}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<SheetContent side="right" className="w-full overflow-hidden bg-background sm:rounded-l-xl sm:border data-[side=right]:sm:!inset-y-4 data-[side=right]:sm:!h-auto data-[side=right]:sm:!w-[45vw] data-[side=right]:sm:!max-w-[45vw]" showCloseButton blurOverlay={false}>
{application && (
<>
<SheetHeader className="p-6 pb-4">
<SheetTitle>App configuration</SheetTitle>
<SheetDescription>
Double-click an application card to open configuration. Review runtime, networking, and advanced settings for{' '}
{application.name}.
</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col gap-6 px-6 pb-6">
<Tabs defaultValue="overview" className="gap-4">
<TabsList className="w-full justify-start" variant="line">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="networking">Networking</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Name</FieldLabel>
<Input value={application.name} readOnly />
</Field>
<Field>
<FieldLabel>Status</FieldLabel>
<Input value={application.effectiveStatus} readOnly />
</Field>
{application.effectiveStatus !== application.status && (
<Field>
<FieldLabel>Last known container status</FieldLabel>
<Input value={application.status} readOnly />
</Field>
)}
<Field>
<FieldLabel>Image</FieldLabel>
<Input value={application.image} readOnly />
</Field>
<Field>
<FieldLabel>Server</FieldLabel>
<Input
value={
application.isServerReachable
? (application.serverName ?? 'Unknown')
: `${application.serverName ?? 'Unknown'} (unreachable)`
}
readOnly
/>
</Field>
<Field>
<FieldLabel>Container</FieldLabel>
<Input value={application.containerName} readOnly />
</Field>
<Field>
<FieldLabel>Runtime container ID</FieldLabel>
<Input value={application.runtimeContainerId ?? 'Not available'} readOnly />
</Field>
</div>
<Field>
<FieldLabel>Status message</FieldLabel>
<Textarea
value={application.effectiveStatusMessage ?? 'No status message yet.'}
readOnly
className="min-h-20"
/>
</Field>
</TabsContent>
<TabsContent value="networking" className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Mesh namespace</FieldLabel>
<Input value={application.meshNamespace} readOnly />
</Field>
<Field>
<FieldLabel>Mesh FQDN</FieldLabel>
<Input value={application.meshFqdn} readOnly />
</Field>
<Field>
<FieldLabel>Internal port</FieldLabel>
<Input value={application.internalPort?.toString() ?? 'Not configured'} readOnly />
</Field>
<Field>
<FieldLabel>Public ingress</FieldLabel>
<Input value={application.ingressEnabled ? 'Enabled' : 'Private'} readOnly />
</Field>
</div>
<Field>
<FieldLabel>Domains</FieldLabel>
<Textarea
value={
application.domains.length > 0
? application.domains.join('\n')
: 'No public domains configured.'
}
readOnly
/>
</Field>
<div className="flex flex-wrap items-center gap-2">
<ApplicationIngressButton application={application} isSaving={isIngressSaving} onToggle={onToggleIngress} />
<span className="text-xs text-muted-foreground">
Use this action to publish or private-route the app through the server ingress.
</span>
</div>
</TabsContent>
<TabsContent value="logs">
<ApplicationLogsTab key={application.id} application={application} />
</TabsContent>
<TabsContent value="advanced" className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel>Application ID</FieldLabel>
<Input value={application.id} readOnly />
</Field>
<Field>
<FieldLabel>Canvas position</FieldLabel>
<Input value={`${application.canvasX}, ${application.canvasY}`} readOnly />
</Field>
</div>
<Field>
<FieldLabel>Raw app config</FieldLabel>
<Textarea
value={JSON.stringify(application, null, 2)}
readOnly
className="min-h-80 font-mono text-xs"
/>
</Field>
</TabsContent>
</Tabs>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,49 @@
import { memo, type PointerEvent } from 'react';
import { statusBadgeClass } from '@/components/canvas/status-badge';
import { cn } from '@/lib/utils';
import type { V5CaddyIngress } from '@/types';
type CaddyIngressCardProps = {
ingress: V5CaddyIngress;
onDragStart: (event: PointerEvent<HTMLDivElement>, ingress: V5CaddyIngress) => void;
};
export const CaddyIngressCard = memo(function CaddyIngressCard({ ingress, onDragStart }: CaddyIngressCardProps) {
return (
<div
className="absolute w-80 select-none overflow-hidden rounded-xl border border-warning/40 bg-card p-4 shadow-xl transition-shadow hover:shadow-2xl"
style={{
transform: `translate3d(${ingress.canvasX}px, ${ingress.canvasY}px, 0)`,
}}
onPointerDown={(event) => onDragStart(event, ingress)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">Caddy ingress</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{ingress.name}</div>
</div>
<span
className={cn(
'shrink-0 rounded-full px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide',
statusBadgeClass(ingress.status),
)}
title={ingress.statusMessage ?? undefined}
>
{ingress.status}
</span>
</div>
<dl className="mt-4 grid gap-2 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="shrink-0 text-muted-foreground">Server</dt>
<dd className="truncate text-right font-medium text-foreground">{ingress.name}</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="shrink-0 text-muted-foreground">Host</dt>
<dd className="truncate text-right font-mono text-[0.6875rem] text-foreground">{ingress.host}</dd>
</div>
</dl>
</div>
);
});
@@ -0,0 +1,38 @@
type CanvasNoticeProps = {
message: string;
description?: string;
onDismiss: () => void;
variant?: 'danger' | 'success' | 'info';
};
const noticeClasses = {
danger: 'border-destructive/40 text-destructive',
success: 'border-emerald-500/40 text-emerald-400',
info: 'border-blue-500/40 text-blue-400',
};
export function CanvasNotice({ message, description, onDismiss, variant = 'danger' }: CanvasNoticeProps) {
return (
<div
className={`fixed right-4 top-20 z-50 flex max-w-sm items-start gap-3 rounded-lg border bg-card p-3 text-sm shadow-lg ${noticeClasses[variant]}`}
>
<div className="min-w-0">
<p className="font-medium">{message}</p>
{description ? (
<p className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{description}
</p>
) : null}
</div>
<button
type="button"
aria-label="Dismiss notice"
onClick={onDismiss}
className="-m-1 rounded p-1 opacity-80 transition hover:bg-background/20 hover:opacity-100"
>
<span aria-hidden="true">×</span>
<span className="sr-only">Dismiss notice</span>
</button>
</div>
);
}
@@ -0,0 +1,139 @@
import { memo } from 'react';
import { MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM } from '@/lib/use-canvas-viewport';
import type { V5NginxServer } from '@/types';
export type CanvasStatusCounts = {
running: number;
failed: number;
unknown: number;
};
type CanvasToolbarProps = {
nginxServers: V5NginxServer[];
selectedNginxServerId: string;
onSelectNginxServer: (serverId: string) => void;
nginxImage: string;
onNginxImageChange: (image: string) => void;
isCreating: boolean;
onDeploy: () => void;
onCenter: () => void;
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
isRefreshing: boolean;
onRefresh: () => void;
applicationsCount: number;
statusCounts: CanvasStatusCounts;
};
export const CanvasToolbar = memo(function CanvasToolbar({
nginxServers,
selectedNginxServerId,
onSelectNginxServer,
nginxImage,
onNginxImageChange,
isCreating,
onDeploy,
onCenter,
zoom,
onZoomIn,
onZoomOut,
isRefreshing,
onRefresh,
applicationsCount,
statusCounts,
}: CanvasToolbarProps) {
return (
<div className="absolute left-4 top-20 z-30 flex max-w-[calc(100%-2rem)] flex-wrap items-center gap-2 rounded-xl border border-border bg-background/95 p-2 shadow-lg backdrop-blur">
<select
aria-label="Select nginx server"
value={selectedNginxServerId}
onChange={(event) => onSelectNginxServer(event.target.value)}
disabled={isCreating || nginxServers.length === 0}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition disabled:cursor-not-allowed disabled:opacity-60"
>
{nginxServers.length === 0 ? (
<option value="">No servers available</option>
) : (
nginxServers.map((server) => (
<option key={server.id} value={server.id}>
{server.name} ({server.host})
</option>
))
)}
</select>
<input
type="text"
aria-label="Nginx image"
value={nginxImage}
onChange={(event) => onNginxImageChange(event.target.value)}
disabled={isCreating}
className="w-72 rounded-lg border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={onDeploy}
disabled={isCreating || nginxServers.length === 0}
className="rounded-lg bg-warning px-3 py-2 text-sm font-semibold text-black transition hover:bg-warning/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreating ? 'Deploying…' : 'Deploy'}
</button>
<button
type="button"
onClick={onCenter}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-foreground transition hover:bg-muted"
>
Center
</button>
<div className="flex items-center overflow-hidden rounded-lg border border-border">
<button
type="button"
aria-label="Zoom out"
onClick={onZoomOut}
className="px-3 py-2 text-sm font-semibold text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
disabled={zoom <= MIN_CANVAS_ZOOM}
>
−
</button>
<span className="min-w-14 border-x border-border px-2 py-2 text-center text-xs font-medium text-muted-foreground">
{Math.round(zoom * 100)}%
</span>
<button
type="button"
aria-label="Zoom in"
onClick={onZoomIn}
className="px-3 py-2 text-sm font-semibold text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
disabled={zoom >= MAX_CANVAS_ZOOM}
>
+
</button>
</div>
<button
type="button"
onClick={onRefresh}
disabled={isRefreshing}
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-foreground transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
>
{isRefreshing ? 'Refreshing…' : 'Refresh state'}
</button>
<div className="hidden items-center gap-2 px-2 text-xs text-muted-foreground sm:flex">
<span>{applicationsCount} apps</span>
<span>•</span>
<span>{statusCounts.running} running</span>
{statusCounts.failed > 0 && (
<>
<span>•</span>
<span className="text-destructive">{statusCounts.failed} failed</span>
</>
)}
{statusCounts.unknown > 0 && (
<>
<span>•</span>
<span>{statusCounts.unknown} unknown</span>
</>
)}
</div>
</div>
);
});
@@ -0,0 +1,111 @@
import { memo, useMemo, type MouseEvent } from 'react';
import { connectorPoint, shortestConnectionPoints, type DraftConnection } from '@/lib/canvas-geometry';
import { cn } from '@/lib/utils';
import type { CanvasConnection } from '@/lib/use-canvas-connections';
import type { V5Application } from '@/types';
type ConnectionLineProps = {
connection: CanvasConnection;
fromApplication: V5Application;
toApplication: V5Application;
isSelected: boolean;
onSelect: (event: MouseEvent<SVGLineElement>, connectionId: string) => void;
};
const ConnectionLine = memo(function ConnectionLine({ connection, fromApplication, toApplication, isSelected, onSelect }: ConnectionLineProps) {
const points = useMemo(() => shortestConnectionPoints(fromApplication, toApplication), [fromApplication, toApplication]);
return (
<g>
<line
x1={points.from.x}
y1={points.from.y}
x2={points.to.x}
y2={points.to.y}
aria-label="Select connection"
className="pointer-events-auto cursor-pointer"
stroke="transparent"
strokeWidth={12}
strokeLinecap="round"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => onSelect(event, connection.id)}
/>
<line
x1={points.from.x}
y1={points.from.y}
x2={points.to.x}
y2={points.to.y}
className={cn('pointer-events-none', isSelected ? 'stroke-destructive' : 'stroke-warning')}
strokeWidth={isSelected ? 4 : 2}
strokeDasharray="6 6"
strokeLinecap="round"
markerEnd={isSelected ? 'url(#dashboard-connection-arrow)' : undefined}
/>
</g>
);
});
type ConnectionLinesProps = {
connections: CanvasConnection[];
applications: V5Application[];
selectedConnectionId: string | null;
draftConnection: DraftConnection | null;
onSelectConnection: (event: MouseEvent<SVGLineElement>, connectionId: string) => void;
};
export function ConnectionLines({ connections, applications, selectedConnectionId, draftConnection, onSelectConnection }: ConnectionLinesProps) {
const applicationsById = useMemo(() => new Map(applications.map((application) => [application.id, application])), [applications]);
const draftFromApplication = draftConnection ? applicationsById.get(draftConnection.from.applicationId) : undefined;
const draftFrom = draftConnection && draftFromApplication ? connectorPoint(draftFromApplication, draftConnection.from.side) : null;
return (
<svg className="pointer-events-none absolute inset-0 overflow-visible">
<defs>
<marker
id="dashboard-connection-arrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="16"
markerHeight="16"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--destructive)" />
</marker>
</defs>
{connections.map((connection) => {
const fromApplication = applicationsById.get(connection.fromApplicationId);
const toApplication = applicationsById.get(connection.toApplicationId);
if (!fromApplication || !toApplication) {
return null;
}
return (
<ConnectionLine
key={connection.id}
connection={connection}
fromApplication={fromApplication}
toApplication={toApplication}
isSelected={selectedConnectionId === connection.id}
onSelect={onSelectConnection}
/>
);
})}
{draftConnection && draftFrom && (
<line
x1={draftFrom.x}
y1={draftFrom.y}
x2={draftConnection.toX}
y2={draftConnection.toY}
className="stroke-warning/70"
strokeWidth={2}
strokeDasharray="6 6"
strokeLinecap="round"
/>
)}
</svg>
);
}
@@ -0,0 +1,146 @@
import { useMemo } from 'react';
import { shortestConnectionPoints } from '@/lib/canvas-geometry';
import { activeConnectionPorts, type CanvasConnection } from '@/lib/use-canvas-connections';
import { cn } from '@/lib/utils';
import type { V5Application } from '@/types';
type ConnectionPortsEditorProps = {
connection: CanvasConnection;
applications: V5Application[];
portInput: string;
onPortInputChange: (connectionId: string, value: string) => void;
onUpdateDirection: (connectionId: string, fromApplicationId: string, toApplicationId: string) => void;
onAddPort: (connectionId: string) => void;
onRemovePort: (connectionId: string, port: string) => void;
onDelete: (connectionId: string) => void;
};
export function ConnectionPortsEditor({
connection,
applications,
portInput,
onPortInputChange,
onUpdateDirection,
onAddPort,
onRemovePort,
onDelete,
}: ConnectionPortsEditorProps) {
const fromApplication = applications.find((candidate) => candidate.id === connection.fromApplicationId);
const toApplication = applications.find((candidate) => candidate.id === connection.toApplicationId);
const points = useMemo(
() => (fromApplication && toApplication ? shortestConnectionPoints(fromApplication, toApplication) : null),
[fromApplication, toApplication],
);
if (!points) {
return null;
}
function applicationDirectionLabel(applicationId: string): string {
const application = applications.find((candidate) => candidate.id === applicationId);
if (!application) {
return 'Unknown app';
}
return `${application.name} (${application.id.slice(0, 8)})`;
}
const activePorts = activeConnectionPorts(connection);
const firstApplicationId = connection.applicationIds[0];
const secondApplicationId = connection.applicationIds[1];
const isForwardDirection =
connection.fromApplicationId === firstApplicationId && connection.toApplicationId === secondApplicationId;
return (
<div
className="absolute z-20 flex w-72 -translate-x-1/2 -translate-y-1/2 flex-col gap-3 rounded-md border border-border bg-card p-3 shadow-lg"
style={{
left: (points.from.x + points.to.x) / 2,
top: (points.from.y + points.to.y) / 2,
}}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="space-y-2">
<div className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">Firewall</div>
<div className="grid gap-1">
<button
type="button"
onClick={() => onUpdateDirection(connection.id, firstApplicationId, secondApplicationId)}
className={cn(
'rounded-sm border px-2 py-1 text-left text-xs transition',
isForwardDirection
? 'border-warning/40 bg-warning/10 text-foreground hover:bg-warning/20'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{applicationDirectionLabel(firstApplicationId)} → {applicationDirectionLabel(secondApplicationId)}
</button>
<button
type="button"
onClick={() => onUpdateDirection(connection.id, secondApplicationId, firstApplicationId)}
className={cn(
'rounded-sm border px-2 py-1 text-left text-xs transition',
!isForwardDirection
? 'border-warning/40 bg-warning/10 text-foreground hover:bg-warning/20'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{applicationDirectionLabel(secondApplicationId)} → {applicationDirectionLabel(firstApplicationId)}
</button>
</div>
</div>
<div className="space-y-2">
<div className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">Allowed ports</div>
<div className="flex flex-wrap gap-1">
{activePorts.length === 0 && <span className="text-xs text-muted-foreground">No ports yet.</span>}
{activePorts.map((port) => (
<button
key={port}
type="button"
onClick={() => onRemovePort(connection.id, port)}
className="rounded-full border border-border bg-muted px-2 py-1 text-[0.625rem] font-medium text-foreground transition hover:border-destructive/40 hover:text-destructive"
>
{port} ×
</button>
))}
</div>
<div className="flex gap-1">
<input
type="number"
min={1}
max={65535}
placeholder="Port"
value={portInput}
onChange={(event) => onPortInputChange(connection.id, event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onAddPort(connection.id);
}
}}
className="min-w-0 flex-1 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground outline-none transition focus:border-warning"
/>
<button
type="button"
onClick={() => onAddPort(connection.id)}
className="rounded-sm border border-border px-2 py-1 text-xs font-medium text-foreground transition hover:bg-muted"
>
Add
</button>
</div>
</div>
<button
type="button"
aria-label="Delete connection"
onClick={() => onDelete(connection.id)}
className="rounded-sm border border-destructive/40 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-destructive transition hover:bg-destructive/10"
>
Delete
</button>
</div>
);
}
@@ -0,0 +1,82 @@
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import type { IngressModalState } from '@/lib/use-application-ingress';
type IngressDialogProps = {
modal: IngressModalState;
isSaving: boolean;
onDomainsChange: (domains: string) => void;
onInternalPortChange: (internalPort: string) => void;
onSubmit: () => void;
onClose: () => void;
};
export function IngressDialog({ modal, isSaving, onDomainsChange, onInternalPortChange, onSubmit, onClose }: IngressDialogProps) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !isSaving) {
onClose();
}
}}
>
<DialogContent className="max-w-lg" showCloseButton>
<DialogHeader>
<DialogTitle>Enable app ingress</DialogTitle>
<DialogDescription>
Route public domains to {modal.application.name} through the server ingress.
</DialogDescription>
</DialogHeader>
<form
className="mt-6 flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<Field>
<FieldLabel>Domains</FieldLabel>
<Input
type="text"
value={modal.domains}
onChange={(event) => onDomainsChange(event.target.value)}
placeholder="example.com, www.example.com"
/>
<span className="text-xs text-muted-foreground">
Use hostnames only, separated by commas. No scheme, path, wildcard, or port.
</span>
</Field>
<Field>
<FieldLabel>Internal port</FieldLabel>
<Input
type="number"
min="1"
max="65535"
value={modal.internalPort}
onChange={(event) => onInternalPortChange(event.target.value)}
placeholder="3000"
/>
</Field>
{modal.error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<p className="font-medium">Ingress update failed</p>
<p className="mt-1 text-destructive/90">{modal.error}</p>
</div>
)}
<div className="flex justify-end">
<Button type="submit" variant="coolify" disabled={isSaving}>
{isSaving ? 'Saving...' : 'Enable ingress'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,19 @@
export function statusBadgeClass(status: string): string {
if (status === 'running') {
return 'bg-emerald-500/15 text-emerald-400';
}
if (['creating', 'starting'].includes(status)) {
return 'bg-warning/15 text-warning';
}
if (status === 'unknown') {
return 'bg-muted text-muted-foreground';
}
if (['failed', 'exited', 'unreachable'].includes(status)) {
return 'bg-destructive/15 text-destructive';
}
return 'bg-muted text-muted-foreground';
}
@@ -0,0 +1,49 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button';
import { cva, type VariantProps } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
coolify:
'border-coollabs bg-coollabs-50 text-coollabs-200 hover:bg-coollabs hover:text-white focus-visible:border-coollabs-100 focus-visible:bg-coollabs focus-visible:text-white dark:border-coollabs-100 dark:bg-coollabs/20 dark:text-white dark:hover:bg-coollabs-100 dark:hover:text-white dark:focus-visible:border-coollabs-100 dark:focus-visible:bg-coollabs-100',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-ring dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:border-ring',
delete: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-ring dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:border-ring',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs': "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-none',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type ButtonProps = React.ComponentProps<typeof ButtonPrimitive> & VariantProps<typeof buttonVariants>;
function Button({ className, variant = 'default', size = 'default', ...props }: ButtonProps) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
@@ -0,0 +1,93 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { XIcon } from '@phosphor-icons/react';
import type * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn('fixed inset-0 bg-background/80 backdrop-blur-sm', className)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Popup> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed left-1/2 top-1/2 z-50 max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border border-border bg-card p-6 shadow-lg outline-none',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
aria-label="Close dialog"
render={<Button type="button" variant="ghost" size="icon-sm" className="absolute top-3 right-3" />}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-header" className={cn('flex flex-col gap-1.5', className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="dialog-footer" className={cn('mt-6 flex justify-end gap-2', className)} {...props} />;
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle };
@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-2 text-xs text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-none px-2 py-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<CaretRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-none bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,35 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
type FieldProps = React.ComponentProps<'label'>;
function Field({ className, ...props }: FieldProps) {
return <label data-slot="field" className={cn('flex flex-col gap-1 text-sm', className)} {...props} />;
}
type FieldLabelProps = React.ComponentProps<'span'>;
function FieldLabel({ className, ...props }: FieldLabelProps) {
return <span data-slot="field-label" className={cn('font-medium text-foreground', className)} {...props} />;
}
type FieldErrorProps = React.ComponentProps<'span'> & {
message?: string;
};
function FieldError({ className, message, ...props }: FieldErrorProps) {
return (
<span
aria-hidden={message ? undefined : true}
aria-live="polite"
data-slot="field-error"
className={cn('min-h-4 text-xs leading-4 text-destructive', className)}
{...props}
>
{message ?? ''}
</span>
);
}
export { Field, FieldError, FieldLabel };
@@ -0,0 +1,18 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, ...props }: React.ComponentProps<'input'>) {
return (
<input
data-slot="input"
className={cn(
'rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50',
className,
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,178 @@
import { Select as SelectPrimitive } from '@base-ui/react/select';
import { CaretDownIcon, CaretUpIcon, CheckIcon } from '@phosphor-icons/react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
type SelectGroupProps = React.ComponentProps<typeof SelectPrimitive.Group>;
function SelectGroup({ className, ...props }: SelectGroupProps) {
return <SelectPrimitive.Group data-slot="select-group" className={cn('scroll-my-1', className)} {...props} />;
}
type SelectValueProps = React.ComponentProps<typeof SelectPrimitive.Value>;
function SelectValue({ className, ...props }: SelectValueProps) {
return <SelectPrimitive.Value data-slot="select-value" className={cn('flex flex-1 text-left', className)} {...props} />;
}
type SelectTriggerProps = React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'default' | 'sm';
variant?: 'default' | 'ghost';
};
function SelectTrigger({ className, size = 'default', variant = 'default', children, ...props }: SelectTriggerProps) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
data-variant={variant}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-none whitespace-nowrap transition-colors outline-none select-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variant === 'default' &&
'border border-input bg-transparent py-2 pr-2 pl-2.5 text-xs focus-visible:border-ring dark:bg-input/30 dark:hover:bg-input/50',
variant === 'ghost' &&
'h-auto border border-transparent bg-transparent px-0 py-0 text-sm font-medium text-foreground hover:bg-transparent focus-visible:border-ring dark:bg-transparent dark:hover:bg-transparent',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<CaretDownIcon className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
);
}
type SelectPositionerProps = React.ComponentProps<typeof SelectPrimitive.Positioner>;
type SelectPopupProps = React.ComponentProps<typeof SelectPrimitive.Popup>;
type SelectContentProps = SelectPopupProps & {
position?: 'popper' | 'item-aligned';
side?: SelectPositionerProps['side'];
sideOffset?: SelectPositionerProps['sideOffset'];
align?: SelectPositionerProps['align'];
alignOffset?: SelectPositionerProps['alignOffset'];
alignItemWithTrigger?: SelectPositionerProps['alignItemWithTrigger'];
};
function SelectContent({
className,
children,
position,
side = 'bottom',
sideOffset = 4,
align = 'center',
alignOffset = 0,
alignItemWithTrigger = position === 'popper' ? false : true,
...props
}: SelectContentProps) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
type SelectLabelProps = React.ComponentProps<typeof SelectPrimitive.GroupLabel>;
function SelectLabel({ className, ...props }: SelectLabelProps) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn('px-2 py-2 text-xs text-muted-foreground', className)}
{...props}
/>
);
}
type SelectItemProps = React.ComponentProps<typeof SelectPrimitive.Item>;
function SelectItem({ className, children, ...props }: SelectItemProps) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-2 text-xs outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
type SelectSeparatorProps = React.ComponentProps<typeof SelectPrimitive.Separator>;
function SelectSeparator({ className, ...props }: SelectSeparatorProps) {
return <SelectPrimitive.Separator data-slot="select-separator" className={cn('pointer-events-none -mx-1 h-px bg-border', className)} {...props} />;
}
type SelectScrollUpButtonProps = React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>;
function SelectScrollUpButton({ className, ...props }: SelectScrollUpButtonProps) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn('top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=\'size-\'])]:size-4', className)}
{...props}
>
<CaretUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
type SelectScrollDownButtonProps = React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>;
function SelectScrollDownButton({ className, ...props }: SelectScrollDownButtonProps) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn('bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*=\'size-\'])]:size-4', className)}
{...props}
>
<CaretDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -0,0 +1,24 @@
'use client';
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator';
import type * as React from 'react';
import { cn } from '@/lib/utils';
type SeparatorProps = React.ComponentProps<typeof SeparatorPrimitive>;
function Separator({ className, orientation = 'horizontal', ...props }: SeparatorProps) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -0,0 +1,96 @@
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog';
import { XIcon } from '@phosphor-icons/react';
import type * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
blur = true,
...props
}: SheetPrimitive.Backdrop.Props & {
blur?: boolean;
}) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/10 text-xs/relaxed transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0',
blur && 'supports-backdrop-filter:backdrop-blur-xs',
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
blurOverlay = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: 'top' | 'right' | 'bottom' | 'left';
showCloseButton?: boolean;
blurOverlay?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay blur={blurOverlay} />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col bg-popover bg-clip-padding text-xs/relaxed text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-header" className={cn('flex flex-col gap-0.5 p-4', className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="sheet-footer" className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />;
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return <SheetPrimitive.Title data-slot="sheet-title" className={cn('font-heading text-sm font-medium text-foreground', className)} {...props} />;
}
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return <SheetPrimitive.Description data-slot="sheet-description" className={cn('text-xs/relaxed text-muted-foreground', className)} {...props} />;
}
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger };
@@ -0,0 +1,63 @@
import { Tabs as TabsPrimitive } from '@base-ui/react/tabs';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
function Tabs({ className, orientation = 'horizontal', ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn('group/tabs flex gap-2 data-horizontal:flex-col', className)}
{...props}
/>
);
}
const tabsListVariants = cva(
'group/tabs-list inline-flex w-fit items-center justify-center rounded-none p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function TabsList({ className, variant = 'default', ...props }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return <TabsPrimitive.Panel data-slot="tabs-content" className={cn('flex-1 text-xs/relaxed outline-none', className)} {...props} />;
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,18 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'min-h-24 rounded-md border border-border bg-background px-3 py-2 text-sm outline-none transition focus:border-ring focus:ring-0 aria-invalid:border-destructive aria-invalid:ring-0 dark:aria-invalid:border-destructive/50',
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-none border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-none data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-none bg-popover fill-popover data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,7 @@
/**
* General-purpose JSON request helper for v5 pages: same-origin credentials,
* CSRF header, and a 30 second timeout on every request. The implementation
* currently lives in canvas-api.ts; import from this module in non-canvas
* code so the canvas-specific name can be retired later.
*/
export { canvasRequest as apiRequest } from '@/lib/canvas-api';
@@ -0,0 +1,24 @@
import { csrfToken } from '@/lib/csrf';
type CanvasRequestOptions = {
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
};
/**
* Shared JSON fetch for the canvas endpoints: same-origin credentials,
* CSRF header, and a 30 second timeout on every request.
*/
export function canvasRequest(url: string, { method, body }: CanvasRequestOptions): Promise<Response> {
return fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
'X-CSRF-TOKEN': csrfToken(),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision';
const GAP = 16;
function node(id: string, x: number, y: number, width = 320, height = 160): CanvasNodeBounds {
return { id, x, y, width, height };
}
function nodesOverlap(first: CanvasNodeBounds, second: CanvasNodeBounds, gap: number): boolean {
return (
first.x < second.x + second.width + gap &&
first.x + first.width + gap > second.x &&
first.y < second.y + second.height + gap &&
first.y + first.height + gap > second.y
);
}
describe('resolveCanvasNodePosition', () => {
it('keeps the position when there is no collision', () => {
const position = resolveCanvasNodePosition(node('a', 0, 0), [node('b', 1000, 1000)], GAP);
expect(position).toEqual({ x: 0, y: 0 });
});
it('ignores the node itself when checking collisions', () => {
const subject = node('a', 0, 0);
const position = resolveCanvasNodePosition(subject, [subject], GAP);
expect(position).toEqual({ x: 0, y: 0 });
});
it('moves an overlapping node to a gap-respecting position', () => {
const settled = node('a', 0, 0);
const position = resolveCanvasNodePosition(node('b', 10, 10), [settled], GAP);
expect(nodesOverlap({ ...node('b', 10, 10), ...position }, settled, GAP)).toBe(false);
});
it('picks the candidate closest to the requested position', () => {
const settled = node('a', 0, 0);
// Dropped near the settled node's top-left corner: the slot above is nearest.
const position = resolveCanvasNodePosition(node('b', 40, 0), [settled], GAP);
expect(position).toEqual({ x: 40, y: -(160 + GAP) });
});
});
describe('resolveCanvasNodeLayout', () => {
it('settles all nodes without overlaps', () => {
const settled = resolveCanvasNodeLayout(
[node('a', 0, 0), node('b', 0, 0), node('c', 0, 0), node('d', 0, 0)],
GAP,
);
expect(settled).toHaveLength(4);
for (const first of settled) {
for (const second of settled) {
if (first.id !== second.id) {
expect(nodesOverlap(first, second, GAP)).toBe(false);
}
}
}
});
it('preserves node order and ids', () => {
const settled = resolveCanvasNodeLayout([node('a', 0, 0), node('b', 0, 0)], GAP);
expect(settled.map((settledNode) => settledNode.id)).toEqual(['a', 'b']);
});
});
@@ -0,0 +1,80 @@
export type CanvasNodeBounds = {
id: string;
x: number;
y: number;
width: number;
height: number;
};
export type CanvasNodePosition = {
x: number;
y: number;
};
export function resolveCanvasNodeLayout(nodes: CanvasNodeBounds[], gap: number): CanvasNodeBounds[] {
return nodes.reduce<CanvasNodeBounds[]>((settledNodes, node) => {
const position = resolveCanvasNodePosition(node, settledNodes, gap);
return [...settledNodes, { ...node, ...position }];
}, []);
}
export function resolveCanvasNodePosition(
node: CanvasNodeBounds,
nodes: CanvasNodeBounds[],
gap: number,
): CanvasNodePosition {
const otherNodes = nodes.filter((otherNode) => otherNode.id !== node.id);
let position = { x: node.x, y: node.y };
for (let attempt = 0; attempt < 50; attempt += 1) {
const collision = otherNodes.find((otherNode) => canvasNodesOverlap({ ...node, ...position }, otherNode, gap));
if (!collision) {
return position;
}
position = closestCanvasNodePosition(node, collision, gap, otherNodes, position);
}
return position;
}
function closestCanvasNodePosition(
node: CanvasNodeBounds,
collision: CanvasNodeBounds,
gap: number,
otherNodes: CanvasNodeBounds[],
targetPosition: CanvasNodePosition,
): CanvasNodePosition {
const candidates = [
{ x: targetPosition.x, y: collision.y - node.height - gap },
{ x: collision.x + collision.width + gap, y: targetPosition.y },
{ x: targetPosition.x, y: collision.y + collision.height + gap },
{ x: collision.x - node.width - gap, y: targetPosition.y },
].sort((firstCandidate, secondCandidate) => {
const firstDistance = canvasDistance(firstCandidate, targetPosition);
const secondDistance = canvasDistance(secondCandidate, targetPosition);
return firstDistance - secondDistance;
});
return (
candidates.find((candidate) =>
otherNodes.every((otherNode) => !canvasNodesOverlap({ ...node, ...candidate }, otherNode, gap)),
) ?? candidates[0]
);
}
function canvasNodesOverlap(node: CanvasNodeBounds, otherNode: CanvasNodeBounds, gap: number): boolean {
return (
node.x < otherNode.x + otherNode.width + gap &&
node.x + node.width + gap > otherNode.x &&
node.y < otherNode.y + otherNode.height + gap &&
node.y + node.height + gap > otherNode.y
);
}
function canvasDistance(firstPosition: CanvasNodePosition, secondPosition: CanvasNodePosition): number {
return Math.hypot(firstPosition.x - secondPosition.x, firstPosition.y - secondPosition.y);
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
APPLICATION_CARD_HEIGHT,
APPLICATION_CARD_WIDTH,
CANVAS_CARD_GAP,
connectorPoint,
resolveApplicationPosition,
settleCanvasResources,
shortestConnectionPoints,
} from '@/lib/canvas-geometry';
import type { V5Application, V5CaddyIngress } from '@/types';
function application(id: string, canvasX: number, canvasY: number): V5Application {
return { id, canvasX, canvasY } as V5Application;
}
function ingress(id: string, canvasX: number, canvasY: number): V5CaddyIngress {
return { id, canvasX, canvasY } as V5CaddyIngress;
}
describe('connectorPoint', () => {
const node = { canvasX: 100, canvasY: 200 };
it('returns the midpoint of each card edge', () => {
expect(connectorPoint(node, 'top')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH / 2, y: 200 });
expect(connectorPoint(node, 'right')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH, y: 200 + APPLICATION_CARD_HEIGHT / 2 });
expect(connectorPoint(node, 'bottom')).toEqual({ x: 100 + APPLICATION_CARD_WIDTH / 2, y: 200 + APPLICATION_CARD_HEIGHT });
expect(connectorPoint(node, 'left')).toEqual({ x: 100, y: 200 + APPLICATION_CARD_HEIGHT / 2 });
});
});
describe('shortestConnectionPoints', () => {
it('connects facing edges of horizontally separated cards', () => {
const left = { canvasX: 0, canvasY: 0 };
const right = { canvasX: 1000, canvasY: 0 };
const { from, to } = shortestConnectionPoints(left, right);
expect(from).toEqual(connectorPoint(left, 'right'));
expect(to).toEqual(connectorPoint(right, 'left'));
});
it('connects facing edges of vertically separated cards', () => {
const top = { canvasX: 0, canvasY: 0 };
const bottom = { canvasX: 0, canvasY: 1000 };
const { from, to } = shortestConnectionPoints(top, bottom);
expect(from).toEqual(connectorPoint(top, 'bottom'));
expect(to).toEqual(connectorPoint(bottom, 'top'));
});
});
describe('settleCanvasResources', () => {
it('keeps non-overlapping resources in place', () => {
const apps = [application('app-1', 0, 0), application('app-2', 1000, 0)];
const settled = settleCanvasResources(apps, []);
expect(settled.applications[0]).toMatchObject({ canvasX: 0, canvasY: 0 });
expect(settled.applications[1]).toMatchObject({ canvasX: 1000, canvasY: 0 });
});
it('separates overlapping applications and ingresses', () => {
const settled = settleCanvasResources([application('app-1', 0, 0)], [ingress('ingress-1', 0, 0)]);
const app = settled.applications[0];
const caddy = settled.ingresses[0];
const horizontalGap = Math.abs(app.canvasX - caddy.canvasX);
const verticalGap = Math.abs(app.canvasY - caddy.canvasY);
expect(
horizontalGap >= APPLICATION_CARD_WIDTH + CANVAS_CARD_GAP || verticalGap >= APPLICATION_CARD_HEIGHT + CANVAS_CARD_GAP,
).toBe(true);
});
});
describe('resolveApplicationPosition', () => {
it('moves a dragged application off an occupied slot', () => {
const occupied = application('app-1', 0, 0);
const dragged = application('app-2', 8, 8);
const resolved = resolveApplicationPosition(dragged, [occupied, dragged], []);
expect(resolved.canvasX !== 8 || resolved.canvasY !== 8).toBe(true);
});
it('keeps a dragged application on a free slot', () => {
const occupied = application('app-1', 0, 0);
const dragged = application('app-2', 1000, 1000);
const resolved = resolveApplicationPosition(dragged, [occupied, dragged], []);
expect(resolved).toMatchObject({ canvasX: 1000, canvasY: 1000 });
});
});
@@ -0,0 +1,132 @@
import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision';
import type { V5Application, V5CaddyIngress } from '@/types';
export const APPLICATION_CARD_WIDTH = 320;
export const APPLICATION_CARD_HEIGHT = 160;
export const CANVAS_CARD_GAP = 16;
export type ConnectorSide = 'top' | 'right' | 'bottom' | 'left';
export const CONNECTOR_SIDES: ConnectorSide[] = ['top', 'right', 'bottom', 'left'];
export type CanvasPoint = {
x: number;
y: number;
};
export type CanvasCardNode = {
canvasX: number;
canvasY: number;
};
export type ConnectionEndpoint = {
applicationId: string;
side: ConnectorSide;
};
export type DraftConnection = {
from: ConnectionEndpoint;
toX: number;
toY: number;
};
export function connectorPoint(node: CanvasCardNode, side: ConnectorSide): CanvasPoint {
switch (side) {
case 'top':
return { x: node.canvasX + APPLICATION_CARD_WIDTH / 2, y: node.canvasY };
case 'right':
return { x: node.canvasX + APPLICATION_CARD_WIDTH, y: node.canvasY + APPLICATION_CARD_HEIGHT / 2 };
case 'bottom':
return { x: node.canvasX + APPLICATION_CARD_WIDTH / 2, y: node.canvasY + APPLICATION_CARD_HEIGHT };
case 'left':
return { x: node.canvasX, y: node.canvasY + APPLICATION_CARD_HEIGHT / 2 };
}
}
export function shortestConnectionPoints(fromNode: CanvasCardNode, toNode: CanvasCardNode): { from: CanvasPoint; to: CanvasPoint } {
let shortest = {
from: connectorPoint(fromNode, 'top'),
to: connectorPoint(toNode, 'top'),
distance: Number.POSITIVE_INFINITY,
};
for (const fromSide of CONNECTOR_SIDES) {
const from = connectorPoint(fromNode, fromSide);
for (const toSide of CONNECTOR_SIDES) {
const to = connectorPoint(toNode, toSide);
const distance = Math.hypot(from.x - to.x, from.y - to.y);
if (distance < shortest.distance) {
shortest = { from, to, distance };
}
}
}
return { from: shortest.from, to: shortest.to };
}
function applicationBounds(application: V5Application): CanvasNodeBounds {
return {
id: `application-${application.id}`,
x: application.canvasX,
y: application.canvasY,
width: APPLICATION_CARD_WIDTH,
height: APPLICATION_CARD_HEIGHT,
};
}
function ingressBounds(ingress: V5CaddyIngress): CanvasNodeBounds {
return {
id: `ingress-${ingress.id}`,
x: ingress.canvasX,
y: ingress.canvasY,
width: APPLICATION_CARD_WIDTH,
height: APPLICATION_CARD_HEIGHT,
};
}
export function canvasCollisionNodes(applications: V5Application[], ingresses: V5CaddyIngress[]): CanvasNodeBounds[] {
return [...applications.map(applicationBounds), ...ingresses.map(ingressBounds)];
}
export function settleCanvasResources(
nextApplications: V5Application[],
nextIngresses: V5CaddyIngress[],
): { applications: V5Application[]; ingresses: V5CaddyIngress[] } {
const settledNodes = resolveCanvasNodeLayout(canvasCollisionNodes(nextApplications, nextIngresses), CANVAS_CARD_GAP);
const positionsById = new Map(settledNodes.map((node) => [node.id, node]));
return {
applications: nextApplications.map((application) => {
const position = positionsById.get(`application-${application.id}`);
return position ? { ...application, canvasX: position.x, canvasY: position.y } : application;
}),
ingresses: nextIngresses.map((ingress) => {
const position = positionsById.get(`ingress-${ingress.id}`);
return position ? { ...ingress, canvasX: position.x, canvasY: position.y } : ingress;
}),
};
}
export function resolveApplicationPosition(
application: V5Application,
applications: V5Application[],
ingresses: V5CaddyIngress[],
): V5Application {
const position = resolveCanvasNodePosition(applicationBounds(application), canvasCollisionNodes(applications, ingresses), CANVAS_CARD_GAP);
return { ...application, canvasX: position.x, canvasY: position.y };
}
export function resolveIngressPosition(
ingress: V5CaddyIngress,
applications: V5Application[],
ingresses: V5CaddyIngress[],
): V5CaddyIngress {
const position = resolveCanvasNodePosition(ingressBounds(ingress), canvasCollisionNodes(applications, ingresses), CANVAS_CARD_GAP);
return { ...ingress, canvasX: position.x, canvasY: position.y };
}
@@ -0,0 +1,3 @@
export function csrfToken(): string {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
}
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest';
import { runOptimisticUpdate } from '@/lib/optimistic';
describe('runOptimisticUpdate', () => {
it('applies, persists, and reconciles on success', async () => {
const calls: string[] = [];
const notify = vi.fn();
const succeeded = await runOptimisticUpdate<string>({
apply: () => calls.push('apply'),
rollback: () => calls.push('rollback'),
request: async () => {
calls.push('request');
return { ok: true, payload: 'server-state' };
},
fallbackErrorMessage: 'fallback',
notify,
onSuccess: (payload) => calls.push(`success:${payload}`),
onSettled: () => calls.push('settled'),
});
expect(succeeded).toBe(true);
expect(calls).toEqual(['apply', 'request', 'success:server-state', 'settled']);
expect(notify).not.toHaveBeenCalled();
});
it('rolls back and notifies with the server error message on failure', async () => {
const rollback = vi.fn();
const notify = vi.fn();
const succeeded = await runOptimisticUpdate({
rollback,
request: async () => ({ ok: false, errorMessage: 'Ports must be integers.' }),
fallbackErrorMessage: 'fallback',
notify,
});
expect(succeeded).toBe(false);
expect(rollback).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledWith('Ports must be integers.');
});
it('falls back to the generic message when the failure carries none', async () => {
const notify = vi.fn();
await runOptimisticUpdate({
request: async () => ({ ok: false }),
fallbackErrorMessage: 'Could not save.',
notify,
});
expect(notify).toHaveBeenCalledWith('Could not save.');
});
it('rolls back and notifies with the thrown error message', async () => {
const rollback = vi.fn();
const notify = vi.fn();
const onSettled = vi.fn();
const succeeded = await runOptimisticUpdate({
rollback,
request: async () => {
throw new Error('Network down.');
},
fallbackErrorMessage: 'fallback',
notify,
onSettled,
});
expect(succeeded).toBe(false);
expect(rollback).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledWith('Network down.');
expect(onSettled).toHaveBeenCalledOnce();
});
it('uses the fallback message for non-Error throws', async () => {
const notify = vi.fn();
await runOptimisticUpdate({
request: async () => {
throw 'string failure';
},
fallbackErrorMessage: 'Could not save.',
notify,
});
expect(notify).toHaveBeenCalledWith('Could not save.');
});
it('runs onSettled even when onSuccess throws', async () => {
const onSettled = vi.fn();
await expect(
runOptimisticUpdate({
request: async () => ({ ok: true, payload: undefined }),
fallbackErrorMessage: 'fallback',
notify: vi.fn(),
onSuccess: () => {
throw new Error('reconcile failed');
},
onSettled,
}),
).resolves.toBe(false);
expect(onSettled).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,57 @@
export type OptimisticRequestResult<TPayload> =
| { ok: true; payload: TPayload }
| { ok: false; errorMessage?: string };
export type OptimisticUpdateOptions<TPayload> = {
/** Apply the optimistic state change before the request is sent. */
apply?: () => void;
/** Restore the previous state after a failed request. */
rollback?: () => void;
/** Perform the persistence request; return ok=false (or throw) on failure. */
request: () => Promise<OptimisticRequestResult<TPayload>>;
/** Notice used when the failure carries no specific error message. */
fallbackErrorMessage: string;
notify: (message: string | null) => void;
/** Reconcile local state with the server payload after success. */
onSuccess?: (payload: TPayload) => void;
/** Runs after success or failure, e.g. to clear pending markers. */
onSettled?: () => void;
};
/**
* Unified optimistic-update flow: apply the local change, persist it, and on
* any failure roll the local state back and surface a notice.
*/
export async function runOptimisticUpdate<TPayload = void>({
apply,
rollback,
request,
fallbackErrorMessage,
notify,
onSuccess,
onSettled,
}: OptimisticUpdateOptions<TPayload>): Promise<boolean> {
apply?.();
try {
const result = await request();
if (!result.ok) {
rollback?.();
notify(result.errorMessage ?? fallbackErrorMessage);
return false;
}
onSuccess?.(result.payload);
return true;
} catch (error) {
rollback?.();
notify(error instanceof Error ? error.message : fallbackErrorMessage);
return false;
} finally {
onSettled?.();
}
}
@@ -0,0 +1,201 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { isValidDomain, useApplicationIngress } from '@/lib/use-application-ingress';
import type { V5Application } from '@/types';
vi.mock('@/lib/canvas-api', () => ({
canvasRequest: vi.fn(),
}));
import { canvasRequest } from '@/lib/canvas-api';
const canvasRequestMock = vi.mocked(canvasRequest);
function application(overrides: Partial<V5Application> = {}): V5Application {
return {
id: 'app-1',
name: 'nginx-test',
serverIngressEnabled: true,
ingressEnabled: false,
internalPort: null,
domains: [],
...overrides,
} as V5Application;
}
function jsonResponse(payload: unknown, ok = true): Response {
return { ok, json: async () => payload } as Response;
}
function renderIngress(notify = vi.fn(), onApplicationUpdated = vi.fn()) {
const rendered = renderHook(() => useApplicationIngress({ notify, onApplicationUpdated }));
return { ...rendered, notify, onApplicationUpdated };
}
beforeEach(() => {
canvasRequestMock.mockReset();
});
describe('isValidDomain', () => {
it.each(['example.com', 'sub.example.com', 'a.io', 'my-app.example.co.uk'])('accepts %s', (domain) => {
expect(isValidDomain(domain)).toBe(true);
});
it.each(['', '.example.com', 'example.com.', '-bad.example.com', 'bad-.example.com', 'exa mple.com', 'UPPER.example.com'])(
'rejects %j',
(domain) => {
expect(isValidDomain(domain)).toBe(false);
},
);
});
describe('toggleApplicationIngress', () => {
it('refuses to open the modal when server ingress is disabled', () => {
const { result, notify } = renderIngress();
act(() => result.current.toggleApplicationIngress(application({ serverIngressEnabled: false })));
expect(notify).toHaveBeenCalledWith('Enable ingress on the server before enabling app ingress.');
expect(result.current.ingressModal).toBeNull();
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('opens the modal prefilled from the application', () => {
const { result } = renderIngress();
act(() =>
result.current.toggleApplicationIngress(application({ domains: ['a.example.com', 'b.example.com'], internalPort: 8080 })),
);
expect(result.current.ingressModal).toMatchObject({
domains: 'a.example.com, b.example.com',
internalPort: '8080',
error: null,
});
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('disables ingress immediately for an enabled application', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ application: application() }));
const enabledApplication = application({ ingressEnabled: true, domains: ['app.example.com'], internalPort: 8080 });
const { result, onApplicationUpdated } = renderIngress();
await act(() => result.current.toggleApplicationIngress(enabledApplication));
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/applications/app-1/ingress', {
method: 'PATCH',
body: { ingress_enabled: false, internal_port: 8080, domains: ['app.example.com'] },
});
expect(onApplicationUpdated).toHaveBeenCalledOnce();
});
});
describe('submitApplicationIngress', () => {
function openModal(result: { current: ReturnType<typeof useApplicationIngress> }, app = application()): void {
act(() => result.current.toggleApplicationIngress(app));
}
it('requires at least one domain', async () => {
const { result } = renderIngress();
openModal(result);
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Add at least one valid domain.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('rejects invalid domains by name', async () => {
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('good.example.com, bad_domain'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('bad_domain is not a valid domain.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it.each(['', '0', '65536', 'abc'])('rejects invalid internal port %j', async (port) => {
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort(port));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Choose a valid internal port between 1 and 65535.');
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('persists deduplicated lowercase domains and closes the modal', async () => {
const updatedApplication = application({ ingressEnabled: true });
canvasRequestMock.mockResolvedValue(jsonResponse({ application: updatedApplication }));
const { result, onApplicationUpdated } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('App.Example.com, app.example.com, other.example.com,'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/applications/app-1/ingress', {
method: 'PATCH',
body: { ingress_enabled: true, internal_port: 8080, domains: ['app.example.com', 'other.example.com'] },
});
expect(onApplicationUpdated).toHaveBeenCalledWith(updatedApplication);
expect(result.current.ingressModal).toBeNull();
});
it('keeps the modal open and shows the server error on failure', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Domain already in use.' }, false));
const { result, onApplicationUpdated } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Domain already in use.');
expect(onApplicationUpdated).not.toHaveBeenCalled();
});
it('reports network failures inside the modal', async () => {
canvasRequestMock.mockRejectedValue(new Error('Network down.'));
const { result } = renderIngress();
openModal(result);
act(() => result.current.setIngressModalDomains('app.example.com'));
act(() => result.current.setIngressModalInternalPort('8080'));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).toBe('Network down.');
});
});
describe('modal editing', () => {
it('clears the error when inputs change', async () => {
const { result } = renderIngress();
act(() => result.current.toggleApplicationIngress(application()));
await act(() => result.current.submitApplicationIngress());
expect(result.current.ingressModal?.error).not.toBeNull();
act(() => result.current.setIngressModalDomains('app.example.com'));
expect(result.current.ingressModal?.error).toBeNull();
});
it('closes the modal on demand', () => {
const { result } = renderIngress();
act(() => result.current.toggleApplicationIngress(application()));
act(() => result.current.closeIngressModal());
expect(result.current.ingressModal).toBeNull();
});
});
@@ -0,0 +1,169 @@
import { useCallback, useRef, useState } from 'react';
import { canvasRequest } from '@/lib/canvas-api';
import { usePendingIds } from '@/lib/use-pending-ids';
import type { CanvasNotify } from '@/lib/use-canvas-connections';
import type { V5Application } from '@/types';
export type IngressModalState = {
application: V5Application;
domains: string;
internalPort: string;
error: string | null;
};
export function isValidDomain(domain: string): boolean {
if (domain.length < 1 || domain.length > 253 || domain.startsWith('.') || domain.endsWith('.')) {
return false;
}
return domain.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
}
/**
* Owns the application ingress modal and the enable/disable persistence flow,
* tracking per-application saving state through usePendingIds.
*/
export function useApplicationIngress(options: {
notify: CanvasNotify;
onApplicationUpdated: (application: V5Application) => void;
}) {
const { notify, onApplicationUpdated } = options;
const [ingressModal, setIngressModal] = useState<IngressModalState | null>(null);
const savingIngressApplications = usePendingIds<string>();
const ingressModalRef = useRef<IngressModalState | null>(null);
ingressModalRef.current = ingressModal;
const saveApplicationIngress = useCallback(
async (application: V5Application, enabled: boolean, domains: string[], internalPort: number | null): Promise<void> => {
notify(null);
savingIngressApplications.start(application.id);
const reportError = (message: string): void => {
if (ingressModalRef.current) {
setIngressModal((currentModal) => (currentModal ? { ...currentModal, error: message } : currentModal));
} else {
notify(message);
}
};
try {
const response = await canvasRequest(`/v5/applications/${application.id}/ingress`, {
method: 'PATCH',
body: {
ingress_enabled: enabled,
internal_port: internalPort,
domains,
},
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
reportError(payload?.message ?? 'Could not update application ingress.');
return;
}
const payload = (await response.json()) as { application: V5Application };
onApplicationUpdated(payload.application);
setIngressModal(null);
} catch (error) {
reportError(error instanceof Error ? error.message : 'Could not update application ingress.');
} finally {
savingIngressApplications.finish(application.id);
}
},
[notify, onApplicationUpdated, savingIngressApplications.start, savingIngressApplications.finish],
);
const openApplicationIngressModal = useCallback(
(application: V5Application): void => {
notify(null);
if (!application.serverIngressEnabled) {
notify('Enable ingress on the server before enabling app ingress.');
return;
}
setIngressModal({
application,
domains: application.domains.join(', '),
internalPort: application.internalPort ? String(application.internalPort) : '',
error: null,
});
},
[notify],
);
const toggleApplicationIngress = useCallback(
(application: V5Application): void => {
if (application.ingressEnabled) {
void saveApplicationIngress(application, false, application.domains, application.internalPort);
} else {
openApplicationIngressModal(application);
}
},
[saveApplicationIngress, openApplicationIngressModal],
);
const submitApplicationIngress = useCallback(async (): Promise<void> => {
const currentModal = ingressModalRef.current;
if (!currentModal) {
return;
}
const domains = currentModal.domains
.split(',')
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean);
const internalPort = Number(currentModal.internalPort);
const invalidDomain = domains.find((domain) => !isValidDomain(domain));
if (domains.length === 0) {
setIngressModal({ ...currentModal, error: 'Add at least one valid domain.' });
return;
}
if (invalidDomain) {
setIngressModal({ ...currentModal, error: `${invalidDomain} is not a valid domain.` });
return;
}
if (!Number.isInteger(internalPort) || internalPort < 1 || internalPort > 65535) {
setIngressModal({ ...currentModal, error: 'Choose a valid internal port between 1 and 65535.' });
return;
}
await saveApplicationIngress(currentModal.application, true, [...new Set(domains)], internalPort);
}, [saveApplicationIngress]);
const closeIngressModal = useCallback((): void => {
setIngressModal(null);
}, []);
const setIngressModalDomains = useCallback((domains: string): void => {
setIngressModal((currentModal) => (currentModal ? { ...currentModal, domains, error: null } : currentModal));
}, []);
const setIngressModalInternalPort = useCallback((internalPort: string): void => {
setIngressModal((currentModal) => (currentModal ? { ...currentModal, internalPort, error: null } : currentModal));
}, []);
return {
ingressModal,
closeIngressModal,
setIngressModalDomains,
setIngressModalInternalPort,
submitApplicationIngress,
toggleApplicationIngress,
savingIngressApplications,
};
}
@@ -0,0 +1,19 @@
import { useTeamChannel } from '@/lib/use-team-channel';
import type { V5Application, V5CaddyIngress } from '@/types';
export type V5CanvasResourceUpdatedEvent = {
application: V5Application | null;
applications?: V5Application[];
caddyIngress: V5CaddyIngress | null;
};
/**
* Subscribes the canvas to the private team channel and forwards
* `.v5.canvas.resource.updated` payloads to the latest onEvent callback
* without resubscribing when the callback identity changes.
*/
export function useCanvasResourceChannel(teamId: number | null, onEvent: (event: V5CanvasResourceUpdatedEvent) => void): void {
useTeamChannel(teamId, '.v5.canvas.resource.updated', (payload) => {
onEvent(payload as V5CanvasResourceUpdatedEvent);
});
}
@@ -0,0 +1,270 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
activeConnectionPorts,
connectionDirectionKey,
pruneConnectionPortsByDirection,
useCanvasConnections,
} from '@/lib/use-canvas-connections';
import type { V5ResourceConnection } from '@/types';
vi.mock('@/lib/canvas-api', () => ({
canvasRequest: vi.fn(),
}));
import { canvasRequest } from '@/lib/canvas-api';
const canvasRequestMock = vi.mocked(canvasRequest);
function connection(overrides: Partial<V5ResourceConnection> = {}): V5ResourceConnection {
return {
id: 'connection-1',
applicationIds: ['app-a', 'app-b'],
fromApplicationId: 'app-a',
toApplicationId: 'app-b',
portsByDirection: { 'app-a->app-b': ['80'] },
...overrides,
};
}
function jsonResponse(payload: unknown, ok = true): Response {
return { ok, json: async () => payload } as Response;
}
beforeEach(() => {
canvasRequestMock.mockReset();
});
describe('connection helpers', () => {
it('builds direction keys and reads active ports', () => {
expect(connectionDirectionKey('app-a', 'app-b')).toBe('app-a->app-b');
expect(activeConnectionPorts(connection())).toEqual(['80']);
expect(activeConnectionPorts(connection({ portsByDirection: {} }))).toEqual([]);
});
it('prunes ports to the active direction only', () => {
const pruned = pruneConnectionPortsByDirection(
connection({ portsByDirection: { 'app-a->app-b': ['80'], 'app-b->app-a': ['443'] } }),
);
expect(pruned).toEqual({ 'app-a->app-b': ['80'] });
});
});
describe('connectionExists', () => {
it('matches connections in both directions', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
expect(result.current.connectionExists('app-a', 'app-b')).toBe(true);
expect(result.current.connectionExists('app-b', 'app-a')).toBe(true);
expect(result.current.connectionExists('app-a', 'app-c')).toBe(false);
});
});
describe('persistNewConnection', () => {
it('adds and selects the persisted connection on success', async () => {
const persisted = connection({ id: 'connection-2', fromApplicationId: 'app-a', toApplicationId: 'app-c' });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([connection()], notify));
await act(() => result.current.persistNewConnection('app-a', 'app-c'));
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections', {
method: 'POST',
body: {
resource_one: { type: 'application', uuid: 'app-a' },
resource_two: { type: 'application', uuid: 'app-c' },
},
});
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-1', 'connection-2']);
expect(result.current.selectedConnectionId).toBe('connection-2');
expect(notify).toHaveBeenCalledWith(null);
expect(notify).not.toHaveBeenCalledWith(expect.stringContaining('Could not'));
});
it('notifies with message and detail on failure', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Connection rejected.', detail: 'Applications overlap.' }, false));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([], notify));
await act(() => result.current.persistNewConnection('app-a', 'app-b'));
expect(notify).toHaveBeenLastCalledWith('Connection rejected. Applications overlap.');
expect(result.current.connections).toEqual([]);
});
});
describe('addConnectionPort', () => {
it.each(['', '0', '65536', '8.5', 'http'])('ignores invalid port draft %j', (draft) => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', draft));
act(() => result.current.addConnectionPort('connection-1'));
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('ignores a port already present for the active direction', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', '80'));
act(() => result.current.addConnectionPort('connection-1'));
expect(canvasRequestMock).not.toHaveBeenCalled();
});
it('optimistically adds the port, persists it, and clears the draft', async () => {
const persisted = connection({ portsByDirection: { 'app-a->app-b': ['80', '443'] } });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setConnectionPortDraft('connection-1', ' 443 '));
act(() => result.current.addConnectionPort('connection-1'));
expect(activeConnectionPorts(result.current.connections[0])).toEqual(['80', '443']);
expect(result.current.connectionPortInput['connection-1']).toBe('');
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', {
method: 'PATCH',
body: { ports_by_direction: { 'app-a->app-b': [80, 443] } },
});
});
});
it('rolls the port back when persistence fails', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({ message: 'Could not save allowed ports.' }, false));
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([connection()], notify));
act(() => result.current.setConnectionPortDraft('connection-1', '443'));
act(() => result.current.addConnectionPort('connection-1'));
await waitFor(() => {
expect(activeConnectionPorts(result.current.connections[0])).toEqual(['80']);
});
expect(notify).toHaveBeenCalledWith('Could not save allowed ports.');
});
});
describe('removeConnectionPort', () => {
it('persists the connection without the removed port', async () => {
const persisted = connection({ portsByDirection: { 'app-a->app-b': [] } });
canvasRequestMock.mockResolvedValue(jsonResponse({ connection: persisted }));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.removeConnectionPort('connection-1', '80'));
expect(activeConnectionPorts(result.current.connections[0])).toEqual([]);
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', {
method: 'PATCH',
body: { ports_by_direction: { 'app-a->app-b': [] } },
});
});
});
});
describe('deleteConnection', () => {
it('optimistically removes the connection and keeps it removed on success', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => result.current.deleteConnection('connection-1'));
expect(result.current.connections).toEqual([]);
expect(result.current.selectedConnectionId).toBeNull();
await waitFor(() => {
expect(canvasRequestMock).toHaveBeenCalledWith('/v5/resource-connections/connection-1', { method: 'DELETE' });
});
expect(result.current.connections).toEqual([]);
});
it('restores the connection at its original index when deletion fails', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}, false));
const first = connection({ id: 'connection-1' });
const second = connection({ id: 'connection-2' });
const notify = vi.fn();
const { result } = renderHook(() => useCanvasConnections([first, second], notify));
act(() => result.current.deleteConnection('connection-1'));
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-2']);
await waitFor(() => {
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-1', 'connection-2']);
});
expect(notify).toHaveBeenCalledWith('Could not delete resource connection.');
});
it('does nothing for an unknown connection id', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.deleteConnection('missing'));
expect(canvasRequestMock).not.toHaveBeenCalled();
expect(result.current.connections).toHaveLength(1);
});
});
describe('updateConnectionDirection', () => {
it('swaps the active direction locally without a request', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.updateConnectionDirection('connection-1', 'app-b', 'app-a'));
expect(result.current.connections[0]).toMatchObject({ fromApplicationId: 'app-b', toApplicationId: 'app-a' });
expect(canvasRequestMock).not.toHaveBeenCalled();
});
});
describe('removeConnectionsForApplication', () => {
it('drops every connection touching the application', () => {
const related = connection({ id: 'connection-1' });
const unrelated = connection({
id: 'connection-2',
applicationIds: ['app-c', 'app-d'],
fromApplicationId: 'app-c',
toApplicationId: 'app-d',
});
const { result } = renderHook(() => useCanvasConnections([related, unrelated], vi.fn()));
act(() => result.current.removeConnectionsForApplication('app-a'));
expect(result.current.connections.map((candidate) => candidate.id)).toEqual(['connection-2']);
});
});
describe('keyboard deletion', () => {
it('deletes the selected connection on Backspace', async () => {
canvasRequestMock.mockResolvedValue(jsonResponse({}));
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => {
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
});
expect(result.current.connections).toEqual([]);
});
it('ignores Backspace while typing in an input', () => {
const { result } = renderHook(() => useCanvasConnections([connection()], vi.fn()));
const input = document.createElement('input');
document.body.appendChild(input);
act(() => result.current.setSelectedConnectionId('connection-1'));
act(() => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
});
expect(result.current.connections).toHaveLength(1);
input.remove();
});
});
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { canvasRequest } from '@/lib/canvas-api';
import { runOptimisticUpdate, type OptimisticRequestResult } from '@/lib/optimistic';
import type { V5ResourceConnection } from '@/types';
export type CanvasConnection = V5ResourceConnection;
export type CanvasNotify = (message: string | null) => void;
export function connectionDirectionKey(fromApplicationId: string, toApplicationId: string): string {
return `${fromApplicationId}->${toApplicationId}`;
}
export function activeConnectionPorts(connection: CanvasConnection): string[] {
return connection.portsByDirection[connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId)] ?? [];
}
export function pruneConnectionPortsByDirection(
connection: CanvasConnection,
fromApplicationId = connection.fromApplicationId,
toApplicationId = connection.toApplicationId,
): Record<string, string[]> {
const directionKey = connectionDirectionKey(fromApplicationId, toApplicationId);
return {
[directionKey]: connection.portsByDirection[directionKey] ?? [],
};
}
function connectionPortsPayload(connection: CanvasConnection): Record<string, number[]> {
return Object.fromEntries(
Object.entries(connection.portsByDirection).map(([direction, ports]) => [
direction,
ports.map((port) => Number(port)).filter((port) => Number.isInteger(port)),
]),
);
}
function preserveActiveDirection(connection: CanvasConnection, activeConnection: CanvasConnection): CanvasConnection {
return {
...connection,
fromApplicationId: activeConnection.fromApplicationId,
toApplicationId: activeConnection.toApplicationId,
};
}
function normalizeConnection(connection: V5ResourceConnection): CanvasConnection {
return {
...connection,
applicationIds: [...connection.applicationIds],
portsByDirection: connection.portsByDirection ?? {},
};
}
function responseErrorMessage(payload: { message?: string; detail?: string }, fallback: string): string {
if (payload.message && payload.detail) {
return `${payload.message} ${payload.detail}`;
}
return payload.message ?? fallback;
}
async function parseConnectionResponse(response: Response, fallback: string): Promise<OptimisticRequestResult<CanvasConnection>> {
const payload = (await response.json()) as { connection?: V5ResourceConnection; message?: string; detail?: string };
if (!response.ok || !payload.connection) {
return { ok: false, errorMessage: responseErrorMessage(payload, fallback) };
}
return { ok: true, payload: normalizeConnection(payload.connection) };
}
/**
* Owns the canvas resource connections: selection, per-direction allowed
* ports, and the optimistic persistence flows for creating, updating, and
* deleting connections.
*/
export function useCanvasConnections(initialConnections: V5ResourceConnection[], notify: CanvasNotify) {
const [connections, setConnections] = useState<CanvasConnection[]>(initialConnections);
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [connectionPortInput, setConnectionPortInput] = useState<Record<string, string>>({});
const connectionsRef = useRef(connections);
connectionsRef.current = connections;
const resetConnections = useCallback((nextConnections: V5ResourceConnection[]): void => {
setConnections(nextConnections);
setSelectedConnectionId(null);
setConnectionPortInput({});
}, []);
const connectionExists = useCallback(
(fromApplicationId: string, toApplicationId: string): boolean =>
connectionsRef.current.some(
(connection) =>
(connection.fromApplicationId === fromApplicationId && connection.toApplicationId === toApplicationId) ||
(connection.fromApplicationId === toApplicationId && connection.toApplicationId === fromApplicationId),
),
[],
);
const replaceConnection = useCallback((nextConnection: CanvasConnection): void => {
setConnections((currentConnections) =>
currentConnections.map((currentConnection) =>
currentConnection.id === nextConnection.id ? nextConnection : currentConnection,
),
);
}, []);
const persistNewConnection = useCallback(
async (fromApplicationId: string, toApplicationId: string): Promise<void> => {
notify(null);
await runOptimisticUpdate<CanvasConnection>({
request: async () => {
const response = await canvasRequest('/v5/resource-connections', {
method: 'POST',
body: {
resource_one: { type: 'application', uuid: fromApplicationId },
resource_two: { type: 'application', uuid: toApplicationId },
},
});
return parseConnectionResponse(response, 'Could not save resource connection.');
},
fallbackErrorMessage: 'Could not save resource connection.',
notify,
onSuccess: (nextConnection) => {
setConnections((currentConnections) => {
const withoutDuplicate = currentConnections.filter((connection) => connection.id !== nextConnection.id);
return [...withoutDuplicate, nextConnection];
});
setSelectedConnectionId(nextConnection.id);
},
});
},
[notify],
);
const persistConnectionPorts = useCallback(
async (updatedConnection: CanvasConnection, previousConnection: CanvasConnection): Promise<void> => {
await runOptimisticUpdate<CanvasConnection>({
apply: () => replaceConnection(updatedConnection),
rollback: () => replaceConnection(previousConnection),
request: async () => {
const response = await canvasRequest(`/v5/resource-connections/${updatedConnection.id}`, {
method: 'PATCH',
body: { ports_by_direction: connectionPortsPayload(updatedConnection) },
});
return parseConnectionResponse(response, 'Could not save allowed ports.');
},
fallbackErrorMessage: 'Could not save allowed ports.',
notify,
onSuccess: (nextConnection) => replaceConnection(preserveActiveDirection(nextConnection, updatedConnection)),
});
},
[notify, replaceConnection],
);
const deleteConnection = useCallback(
(connectionId: string): void => {
const connectionIndex = connectionsRef.current.findIndex((candidate) => candidate.id === connectionId);
const connection = connectionsRef.current[connectionIndex];
if (!connection) {
return;
}
setSelectedConnectionId(null);
void runOptimisticUpdate({
apply: () => setConnections((currentConnections) => currentConnections.filter((candidate) => candidate.id !== connectionId)),
rollback: () =>
setConnections((currentConnections) => {
if (currentConnections.some((candidate) => candidate.id === connectionId)) {
return currentConnections;
}
const restoredConnections = [...currentConnections];
restoredConnections.splice(Math.min(connectionIndex, restoredConnections.length), 0, connection);
return restoredConnections;
}),
request: async () => {
const response = await canvasRequest(`/v5/resource-connections/${connectionId}`, { method: 'DELETE' });
return response.ok ? { ok: true, payload: undefined } : { ok: false };
},
fallbackErrorMessage: 'Could not delete resource connection.',
notify,
});
},
[notify],
);
const updateConnectionDirection = useCallback(
(connectionId: string, fromApplicationId: string, toApplicationId: string): void => {
const connection = connectionsRef.current.find((candidate) => candidate.id === connectionId);
if (!connection) {
return;
}
const updatedConnection = {
...connection,
fromApplicationId,
toApplicationId,
};
replaceConnection(updatedConnection);
},
[replaceConnection],
);
const setConnectionPortDraft = useCallback((connectionId: string, value: string): void => {
setConnectionPortInput((currentInputs) => ({ ...currentInputs, [connectionId]: value }));
}, []);
const addConnectionPort = useCallback(
(connectionId: string): void => {
const port = connectionPortInput[connectionId]?.trim();
const portNumber = Number(port);
if (!port || !Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
return;
}
const connection = connectionsRef.current.find((candidate) => candidate.id === connectionId);
if (!connection) {
return;
}
const directionKey = connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId);
const directionPorts = connection.portsByDirection[directionKey] ?? [];
if (directionPorts.includes(port)) {
return;
}
const updatedConnection = {
...connection,
portsByDirection: {
...connection.portsByDirection,
[directionKey]: [...directionPorts, port],
},
};
void persistConnectionPorts(updatedConnection, connection);
setConnectionPortInput((currentInputs) => ({ ...currentInputs, [connectionId]: '' }));
},
[connectionPortInput, persistConnectionPorts],
);
const removeConnectionPort = useCallback(
(connectionId: string, port: string): void => {
const connection = connectionsRef.current.find((candidate) => candidate.id === connectionId);
if (!connection) {
return;
}
const directionKey = connectionDirectionKey(connection.fromApplicationId, connection.toApplicationId);
const updatedConnection = {
...connection,
portsByDirection: {
...connection.portsByDirection,
[directionKey]: activeConnectionPorts(connection).filter((allowedPort) => allowedPort !== port),
},
};
void persistConnectionPorts(updatedConnection, connection);
},
[persistConnectionPorts],
);
const removeConnectionsForApplication = useCallback((applicationId: string): void => {
setConnections((currentConnections) =>
currentConnections.filter(
(connection) => connection.fromApplicationId !== applicationId && connection.toApplicationId !== applicationId,
),
);
}, []);
useEffect(() => {
function deleteSelectedConnection(event: KeyboardEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) {
return;
}
if (!['Backspace', 'Delete'].includes(event.key) || !selectedConnectionId) {
return;
}
deleteConnection(selectedConnectionId);
}
window.addEventListener('keydown', deleteSelectedConnection);
return () => window.removeEventListener('keydown', deleteSelectedConnection);
}, [selectedConnectionId, deleteConnection]);
return {
connections,
selectedConnectionId,
setSelectedConnectionId,
connectionPortInput,
setConnectionPortDraft,
resetConnections,
connectionExists,
persistNewConnection,
deleteConnection,
updateConnectionDirection,
addConnectionPort,
removeConnectionPort,
removeConnectionsForApplication,
};
}
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useCanvasResourceMerge } from '@/lib/use-canvas-resource-merge';
import type { V5CanvasResourceUpdatedEvent } from '@/lib/use-canvas-channel';
import type { V5Application, V5CaddyIngress } from '@/types';
const channelHandlers: Array<(event: V5CanvasResourceUpdatedEvent) => void> = [];
vi.mock('@/lib/use-canvas-channel', () => ({
useCanvasResourceChannel: (_teamId: number | null, onEvent: (event: V5CanvasResourceUpdatedEvent) => void) => {
channelHandlers.push(onEvent);
},
}));
function application(overrides: Partial<V5Application> = {}): V5Application {
return {
id: 'app-1',
name: 'nginx-test',
status: 'running',
projectUuid: 'project-1',
environmentUuid: 'env-1',
canvasX: 0,
canvasY: 0,
...overrides,
} as V5Application;
}
function ingress(overrides: Partial<V5CaddyIngress> = {}): V5CaddyIngress {
return {
id: 'ingress-1',
name: 'caddy',
status: 'running',
canvasX: 0,
canvasY: 0,
...overrides,
} as V5CaddyIngress;
}
type HarnessOptions = {
initialApplications?: V5Application[];
initialIngresses?: V5CaddyIngress[];
selectedProjectUuid?: string | null;
selectedEnvironmentUuid?: string | null;
locallyPositionedApplicationIds?: Set<string>;
locallyPositionedIngressIds?: Set<string>;
};
function renderMergeHarness({
initialApplications = [],
initialIngresses = [],
selectedProjectUuid = 'project-1',
selectedEnvironmentUuid = 'env-1',
locallyPositionedApplicationIds = new Set<string>(),
locallyPositionedIngressIds = new Set<string>(),
}: HarnessOptions = {}) {
let applications = initialApplications;
let ingresses = initialIngresses;
const setApplications = (update: V5Application[] | ((current: V5Application[]) => V5Application[])): void => {
applications = typeof update === 'function' ? update(applications) : update;
};
const setIngresses = (update: V5CaddyIngress[] | ((current: V5CaddyIngress[]) => V5CaddyIngress[])): void => {
ingresses = typeof update === 'function' ? update(ingresses) : update;
};
renderHook(() =>
useCanvasResourceMerge({
teamId: 1,
selectedProjectUuid,
selectedEnvironmentUuid,
setApplications,
setIngresses,
locallyPositionedApplicationIds: { current: locallyPositionedApplicationIds },
locallyPositionedIngressIds: { current: locallyPositionedIngressIds },
}),
);
const emit = (event: Partial<V5CanvasResourceUpdatedEvent>): void => {
act(() => {
channelHandlers.at(-1)?.({ application: null, caddyIngress: null, ...event });
});
};
return {
emit,
applications: () => applications,
ingresses: () => ingresses,
};
}
beforeEach(() => {
channelHandlers.length = 0;
});
describe('useCanvasResourceMerge', () => {
it('updates an existing application in place', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ status: 'failed', canvasX: 100 }) });
expect(harness.applications()).toEqual([expect.objectContaining({ id: 'app-1', status: 'failed', canvasX: 100 })]);
});
it('keeps local canvas position for cards mid-drag', () => {
const harness = renderMergeHarness({
initialApplications: [application({ canvasX: 500, canvasY: 300 })],
locallyPositionedApplicationIds: new Set(['app-1']),
});
harness.emit({ application: application({ status: 'failed', canvasX: 0, canvasY: 0 }) });
expect(harness.applications()).toEqual([
expect.objectContaining({ id: 'app-1', status: 'failed', canvasX: 500, canvasY: 300 }),
]);
});
it('appends unknown applications belonging to the selected project and environment', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ id: 'app-2', projectUuid: 'project-1', environmentUuid: 'env-1' }) });
expect(harness.applications().map((candidate) => candidate.id)).toEqual(['app-1', 'app-2']);
});
it('ignores unknown applications from other projects or environments', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({ application: application({ id: 'app-2', projectUuid: 'project-9' }) });
harness.emit({ application: application({ id: 'app-3', environmentUuid: 'env-9' }) });
expect(harness.applications().map((candidate) => candidate.id)).toEqual(['app-1']);
});
it('never appends when no project or environment is selected', () => {
const harness = renderMergeHarness({ selectedProjectUuid: null, selectedEnvironmentUuid: null });
harness.emit({ application: application() });
expect(harness.applications()).toEqual([]);
});
it('merges bulk application payloads', () => {
const harness = renderMergeHarness({ initialApplications: [application()] });
harness.emit({
applications: [application({ status: 'failed' }), application({ id: 'app-2' })],
});
expect(harness.applications()).toEqual([
expect.objectContaining({ id: 'app-1', status: 'failed' }),
expect.objectContaining({ id: 'app-2' }),
]);
});
it('updates known ingresses in place but never appends unknown ones', () => {
const harness = renderMergeHarness({ initialIngresses: [ingress()] });
harness.emit({ caddyIngress: ingress({ status: 'stopped' }) });
harness.emit({ caddyIngress: ingress({ id: 'ingress-9' }) });
expect(harness.ingresses()).toEqual([expect.objectContaining({ id: 'ingress-1', status: 'stopped' })]);
});
it('keeps local ingress position for cards mid-drag', () => {
const harness = renderMergeHarness({
initialIngresses: [ingress({ canvasX: 250, canvasY: 100 })],
locallyPositionedIngressIds: new Set(['ingress-1']),
});
harness.emit({ caddyIngress: ingress({ status: 'stopped', canvasX: 0, canvasY: 0 }) });
expect(harness.ingresses()).toEqual([
expect.objectContaining({ id: 'ingress-1', status: 'stopped', canvasX: 250, canvasY: 100 }),
]);
});
});
@@ -0,0 +1,98 @@
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react';
import { useCanvasResourceChannel, type V5CanvasResourceUpdatedEvent } from '@/lib/use-canvas-channel';
import type { V5Application, V5CaddyIngress } from '@/types';
type UseCanvasResourceMergeOptions = {
teamId: number | null;
selectedProjectUuid: string | null;
selectedEnvironmentUuid: string | null;
setApplications: Dispatch<SetStateAction<V5Application[]>>;
setIngresses: Dispatch<SetStateAction<V5CaddyIngress[]>>;
/** Cards mid-drag or with an unsaved position keep their local canvasX/canvasY. */
locallyPositionedApplicationIds: RefObject<Set<string>>;
locallyPositionedIngressIds: RefObject<Set<string>>;
};
/**
* Merges `.v5.canvas.resource.updated` broadcasts into the canvas state.
*
* The team channel broadcasts every canvas resource of the team, so incoming
* applications are only appended when they belong to the selected
* project+environment; existing entries are updated in place regardless.
*/
export function useCanvasResourceMerge({
teamId,
selectedProjectUuid,
selectedEnvironmentUuid,
setApplications,
setIngresses,
locallyPositionedApplicationIds,
locallyPositionedIngressIds,
}: UseCanvasResourceMergeOptions): void {
const belongsToCurrentCanvas = useCallback(
(application: V5Application): boolean =>
selectedProjectUuid !== null &&
selectedEnvironmentUuid !== null &&
application.projectUuid === selectedProjectUuid &&
application.environmentUuid === selectedEnvironmentUuid,
[selectedProjectUuid, selectedEnvironmentUuid],
);
const mergeIncomingApplications = useCallback(
(incomingApplications: V5Application[]): void => {
setApplications((currentApplications) => {
const updatedApplications = currentApplications.map((application) => {
const incoming = incomingApplications.find((candidate) => candidate.id === application.id);
if (!incoming) {
return application;
}
return locallyPositionedApplicationIds.current.has(incoming.id)
? { ...incoming, canvasX: application.canvasX, canvasY: application.canvasY }
: incoming;
});
const appendedApplications = incomingApplications.filter(
(candidate) =>
!currentApplications.some((application) => application.id === candidate.id) &&
belongsToCurrentCanvas(candidate),
);
return [...updatedApplications, ...appendedApplications];
});
},
[belongsToCurrentCanvas, setApplications, locallyPositionedApplicationIds],
);
const handleCanvasResourceEvent = useCallback(
(event: V5CanvasResourceUpdatedEvent): void => {
if (event.application) {
mergeIncomingApplications([event.application]);
}
if (event.applications && event.applications.length > 0) {
mergeIncomingApplications(event.applications);
}
const incomingIngress = event.caddyIngress;
if (incomingIngress) {
setIngresses((currentIngresses) =>
currentIngresses.map((ingress) => {
if (ingress.id !== incomingIngress.id) {
return ingress;
}
return locallyPositionedIngressIds.current.has(incomingIngress.id)
? { ...incomingIngress, canvasX: ingress.canvasX, canvasY: ingress.canvasY }
: incomingIngress;
}),
);
}
},
[mergeIncomingApplications, setIngresses, locallyPositionedIngressIds],
);
useCanvasResourceChannel(teamId, handleCanvasResourceEvent);
}
@@ -0,0 +1,172 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { PointerEvent, WheelEvent } from 'react';
import { APPLICATION_CARD_HEIGHT, APPLICATION_CARD_WIDTH } from '@/lib/canvas-geometry';
import { MAX_CANVAS_ZOOM, MIN_CANVAS_ZOOM, PINCH_CANVAS_ZOOM_STEP, useCanvasViewport } from '@/lib/use-canvas-viewport';
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
function canvasElement(): HTMLDivElement {
const element = document.createElement('div');
element.getBoundingClientRect = () =>
({ left: 0, top: 0, width: CANVAS_WIDTH, height: CANVAS_HEIGHT, right: CANVAS_WIDTH, bottom: CANVAS_HEIGHT, x: 0, y: 0 }) as DOMRect;
return element;
}
function renderViewport() {
const rendered = renderHook(() => useCanvasViewport());
rendered.result.current.canvasRef.current = canvasElement();
return rendered;
}
describe('zoomCanvas', () => {
it('zooms in by one step around the canvas center', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1));
expect(result.current.viewport.zoom).toBeCloseTo(1.1);
// The canvas point under the viewport center must stay put.
expect((CANVAS_WIDTH / 2 - result.current.viewport.x) / result.current.viewport.zoom).toBeCloseTo(CANVAS_WIDTH / 2);
expect((CANVAS_HEIGHT / 2 - result.current.viewport.y) / result.current.viewport.zoom).toBeCloseTo(CANVAS_HEIGHT / 2);
});
it('keeps an explicit zoom origin stable', () => {
const { result } = renderViewport();
const origin = { x: 100, y: 50 };
act(() => result.current.zoomCanvas(1, 0.5, origin));
const { x, y, zoom } = result.current.viewport;
expect(zoom).toBeCloseTo(1.5);
expect((origin.x - x) / zoom).toBeCloseTo(origin.x);
expect((origin.y - y) / zoom).toBeCloseTo(origin.y);
});
it('clamps zoom to the maximum', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1, 10));
expect(result.current.viewport.zoom).toBe(MAX_CANVAS_ZOOM);
});
it('clamps zoom to the minimum', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(-1, 10));
expect(result.current.viewport.zoom).toBe(MIN_CANVAS_ZOOM);
});
it('does nothing when the canvas element is not mounted', () => {
const { result } = renderHook(() => useCanvasViewport());
act(() => result.current.zoomCanvas(1));
expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: 1 });
});
});
describe('handleCanvasWheel', () => {
function wheelEvent(overrides: Partial<{ ctrlKey: boolean; deltaY: number; clientX: number; clientY: number }>) {
return {
ctrlKey: true,
deltaY: -1,
clientX: 0,
clientY: 0,
currentTarget: canvasElement(),
preventDefault: () => {},
...overrides,
} as unknown as WheelEvent<HTMLDivElement>;
}
it('pinch-zooms in on ctrl+wheel up', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ deltaY: -1 })));
expect(result.current.viewport.zoom).toBeCloseTo(1 + PINCH_CANVAS_ZOOM_STEP);
});
it('pinch-zooms out on ctrl+wheel down', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ deltaY: 1 })));
expect(result.current.viewport.zoom).toBeCloseTo(1 - PINCH_CANVAS_ZOOM_STEP);
});
it('ignores plain scrolling without ctrl', () => {
const { result } = renderViewport();
act(() => result.current.handleCanvasWheel(wheelEvent({ ctrlKey: false })));
expect(result.current.viewport.zoom).toBe(1);
});
});
describe('centerOnCanvasNodes', () => {
it('centers a single card in the canvas', () => {
const { result } = renderViewport();
act(() => result.current.centerOnCanvasNodes([{ canvasX: 0, canvasY: 0 }], []));
expect(result.current.viewport).toEqual({
x: CANVAS_WIDTH / 2 - APPLICATION_CARD_WIDTH / 2,
y: CANVAS_HEIGHT / 2 - APPLICATION_CARD_HEIGHT / 2,
zoom: 1,
});
});
it('centers on the midpoint of multiple nodes', () => {
const { result } = renderViewport();
act(() => result.current.centerOnCanvasNodes([{ canvasX: 0, canvasY: 0 }], [{ canvasX: 400, canvasY: 200 }]));
expect(result.current.viewport).toEqual({
x: CANVAS_WIDTH / 2 - (200 + APPLICATION_CARD_WIDTH / 2),
y: CANVAS_HEIGHT / 2 - (100 + APPLICATION_CARD_HEIGHT / 2),
zoom: 1,
});
});
it('resets the pan but keeps the zoom when there are no nodes', () => {
const { result } = renderViewport();
act(() => result.current.zoomCanvas(1));
act(() => result.current.centerOnCanvasNodes([], []));
expect(result.current.viewport).toEqual({ x: 0, y: 0, zoom: result.current.viewport.zoom });
expect(result.current.viewport.x).toBe(0);
expect(result.current.viewport.y).toBe(0);
});
});
describe('canvasPointFromPointer', () => {
function pointerEvent(clientX: number, clientY: number): PointerEvent {
return { clientX, clientY } as PointerEvent;
}
it('maps client coordinates through pan and zoom', () => {
const { result } = renderViewport();
act(() => result.current.setViewport({ x: 100, y: 50, zoom: 2 }));
expect(result.current.canvasPointFromPointer(pointerEvent(300, 250))).toEqual({ x: 100, y: 100 });
});
it('returns the origin when the canvas element is not mounted', () => {
const { result } = renderHook(() => useCanvasViewport());
expect(result.current.canvasPointFromPointer(pointerEvent(300, 250))).toEqual({ x: 0, y: 0 });
});
});
@@ -0,0 +1,125 @@
import { useCallback, useRef, useState, type PointerEvent, type WheelEvent } from 'react';
import { APPLICATION_CARD_HEIGHT, APPLICATION_CARD_WIDTH, type CanvasCardNode } from '@/lib/canvas-geometry';
export type Viewport = {
x: number;
y: number;
zoom: number;
};
export const MIN_CANVAS_ZOOM = 0.5;
export const MAX_CANVAS_ZOOM = 2;
export const CANVAS_ZOOM_STEP = 0.1;
export const PINCH_CANVAS_ZOOM_STEP = 0.03;
function clampCanvasZoom(zoom: number): number {
return Math.min(MAX_CANVAS_ZOOM, Math.max(MIN_CANVAS_ZOOM, zoom));
}
export function useCanvasViewport() {
const canvasRef = useRef<HTMLDivElement | null>(null);
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 });
const viewportRef = useRef(viewport);
viewportRef.current = viewport;
const zoomCanvas = useCallback((direction: 1 | -1, step = CANVAS_ZOOM_STEP, origin?: { x: number; y: number }): void => {
const rect = canvasRef.current?.getBoundingClientRect();
setViewport((currentViewport) => {
const nextZoom = clampCanvasZoom(currentViewport.zoom + direction * step);
if (!rect || nextZoom === currentViewport.zoom) {
return currentViewport;
}
const originX = origin?.x ?? rect.width / 2;
const originY = origin?.y ?? rect.height / 2;
const canvasX = (originX - currentViewport.x) / currentViewport.zoom;
const canvasY = (originY - currentViewport.y) / currentViewport.zoom;
return {
x: originX - canvasX * nextZoom,
y: originY - canvasY * nextZoom,
zoom: nextZoom,
};
});
}, []);
const handleCanvasWheel = useCallback(
(event: WheelEvent<HTMLDivElement>): void => {
if (!event.ctrlKey) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
event.preventDefault();
zoomCanvas(event.deltaY < 0 ? 1 : -1, PINCH_CANVAS_ZOOM_STEP, {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
},
[zoomCanvas],
);
const centerOnCanvasNodes = useCallback((applications: CanvasCardNode[], ingresses: CanvasCardNode[]): void => {
const canvas = canvasRef.current;
const nodes = [...applications, ...ingresses];
if (!canvas || nodes.length === 0) {
setViewport((currentViewport) => ({ x: 0, y: 0, zoom: currentViewport.zoom }));
return;
}
const bounds = nodes.reduce(
(currentBounds, node) => ({
minX: Math.min(currentBounds.minX, node.canvasX),
maxX: Math.max(currentBounds.maxX, node.canvasX),
minY: Math.min(currentBounds.minY, node.canvasY),
maxY: Math.max(currentBounds.maxY, node.canvasY),
}),
{
minX: nodes[0]?.canvasX ?? 0,
maxX: nodes[0]?.canvasX ?? 0,
minY: nodes[0]?.canvasY ?? 0,
maxY: nodes[0]?.canvasY ?? 0,
},
);
const centerX = (bounds.minX + bounds.maxX) / 2;
const centerY = (bounds.minY + bounds.maxY) / 2;
const rect = canvas.getBoundingClientRect();
setViewport((currentViewport) => ({
x: rect.width / 2 - (centerX + APPLICATION_CARD_WIDTH / 2) * currentViewport.zoom,
y: rect.height / 2 - (centerY + APPLICATION_CARD_HEIGHT / 2) * currentViewport.zoom,
zoom: currentViewport.zoom,
}));
}, []);
const canvasPointFromPointer = useCallback((event: PointerEvent): { x: number; y: number } => {
const rect = canvasRef.current?.getBoundingClientRect();
const currentViewport = viewportRef.current;
if (!rect) {
return { x: 0, y: 0 };
}
return {
x: (event.clientX - rect.left - currentViewport.x) / currentViewport.zoom,
y: (event.clientY - rect.top - currentViewport.y) / currentViewport.zoom,
};
}, []);
return {
canvasRef,
viewport,
setViewport,
zoomCanvas,
handleCanvasWheel,
centerOnCanvasNodes,
canvasPointFromPointer,
};
}
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react';
export function usePendingIds<T extends string | number>() {
const [pendingIds, setPendingIds] = useState<Set<T>>(() => new Set());
const start = useCallback((id: T): void => {
setPendingIds((currentIds) => new Set(currentIds).add(id));
}, []);
const finish = useCallback((id: T): void => {
setPendingIds((currentIds) => {
const nextIds = new Set(currentIds);
nextIds.delete(id);
return nextIds;
});
}, []);
const has = useCallback((id: T): boolean => pendingIds.has(id), [pendingIds]);
return {
pendingIds,
has,
hasAny: pendingIds.size > 0,
start,
finish,
};
}
@@ -0,0 +1,116 @@
import { useEffect, useRef } from 'react';
type EchoChannel = {
listen: (event: string, callback: (payload: unknown) => void) => EchoChannel;
subscribed?: (callback: () => void) => EchoChannel;
error?: (callback: (error: unknown) => void) => EchoChannel;
};
type EchoClient = {
private: (channel: string) => EchoChannel;
leave?: (channel: string) => void;
leaveChannel?: (channel: string) => void;
};
declare global {
interface Window {
Echo?: EchoClient;
}
}
export type TeamChannelLogger = (message: string, payload?: unknown) => void;
export type UseTeamChannelOptions = {
/** Receives subscription lifecycle messages; defaults to console.debug. */
onDebug?: TeamChannelLogger;
/** Receives subscription failures; defaults to console.error. */
onError?: TeamChannelLogger;
};
/**
* Subscribes to the private `team.{teamId}` channel and forwards `eventName`
* payloads to the latest onEvent callback without resubscribing when the
* callback identity changes. Polls for window.Echo (500ms, up to 20 attempts)
* because the Echo script can finish loading after React mounts.
*/
export function useTeamChannel(
teamId: number | null,
eventName: string,
onEvent: (payload: unknown) => void,
options: UseTeamChannelOptions = {},
): void {
const onEventRef = useRef(onEvent);
const onDebugRef = useRef(options.onDebug);
const onErrorRef = useRef(options.onError);
onEventRef.current = onEvent;
onDebugRef.current = options.onDebug;
onErrorRef.current = options.onError;
useEffect(() => {
if (teamId === null) {
return;
}
const debug: TeamChannelLogger = (message, payload) => {
if (onDebugRef.current) {
onDebugRef.current(message, payload);
return;
}
console.debug(message);
};
const fail: TeamChannelLogger = (message, payload) => {
if (onErrorRef.current) {
onErrorRef.current(message, payload);
return;
}
console.error(message, payload);
};
let isCancelled = false;
let attempts = 0;
const channelName = `team.${teamId}`;
const interval = window.setInterval(() => {
attempts += 1;
if (!window.Echo) {
if (attempts === 1) {
debug(`Waiting for window.Echo before subscribing to ${eventName} updates`);
}
if (attempts >= 20) {
window.clearInterval(interval);
fail('window.Echo was not available after 10 seconds.');
}
return;
}
window.clearInterval(interval);
if (isCancelled) {
return;
}
debug(`Subscribing to private-${channelName} for ${eventName} updates`);
const channel = window.Echo.private(channelName);
channel.subscribed?.(() => debug(`Subscribed to private-${channelName} for ${eventName} updates`));
channel.error?.((error) => fail(`Subscription error on private-${channelName}`, error));
channel.listen(eventName, (payload) => {
onEventRef.current(payload);
});
}, 500);
return () => {
isCancelled = true;
window.clearInterval(interval);
window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`);
};
}, [teamId, eventName]);
}
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+154
View File
@@ -0,0 +1,154 @@
export type FluxStatus = {
available?: boolean;
label?: string | null;
message?: string | null;
socket?: string | null;
};
export type V5Server = {
id: string;
name: string;
host: string;
status: string;
capabilities: string[];
builderEnabled: boolean;
builderCapacity: number;
builderCpuQuota: string;
ingressEnabled: boolean;
ingressType: string | null;
uuid: string | null;
nodeAddress: string | null;
wireguardListenPortOverride: number | null;
wireguardEndpointOverride: string | null;
wireguardManagementIp: string | null;
wireguardPublicKey: string | null;
/** Namespace → subnet map; serialized as an empty JSON array when no subnets are recorded. */
containerSubnets: Record<string, string> | [];
privateKeyName: string | null;
lastBootstrappedAt: string | null;
lastBootstrapAction: string | null;
lastBootstrapStatus: string | null;
lastBootstrapOutput: string | null;
lastBootstrapRanAt: string | null;
lastStatusOutput: string | null;
lastStatusCheckedAt: string | null;
};
export type V5Cluster = {
id: string;
name: string;
description: string | null;
wireguardInterface: string;
wireguardManagementPool: string;
wireguardListenPort: number;
containerNetworkPool: string;
containerNetworkPrefix: number;
namespaces: string[];
defaultDenyContainers: boolean;
cooldVersion: string;
corrosionVersion: string;
corrosionGossipPort: number;
corrosionApiPort: number;
builderEnabled: boolean;
builderCapacity: number;
builderCpuQuota: string;
builderMemoryMax: string;
builderTimeoutSecs: number;
lastCliAction: string | null;
lastCliStatus: string | null;
lastCliSummary: string | null;
lastCliRanAt: string | null;
serversCount: number;
servers: V5Server[];
};
export type V5Environment = {
uuid: string;
name: string;
};
export type V5Project = {
uuid: string;
name: string;
environments: V5Environment[];
};
export type V5PrivateKey = {
id: string;
name: string;
};
export type V5NginxServer = {
id: string;
name: string;
host: string;
status: string;
};
export type V5Application = {
id: string;
name: string;
image: string;
containerName: string;
status: 'creating' | 'running' | 'failed' | string;
statusMessage: string | null;
effectiveStatus: 'creating' | 'running' | 'failed' | 'unknown' | string;
effectiveStatusMessage: string | null;
runtimeContainerId: string | null;
serverName: string | null;
serverStatus: string | null;
serverStatusMessage: string | null;
isServerReachable: boolean;
serverIngressEnabled: boolean;
meshNamespace: string;
ingressEnabled: boolean;
internalPort: number | null;
domains: string[];
meshFqdn: string;
projectUuid: string | null;
environmentUuid: string | null;
canvasX: number;
canvasY: number;
};
export type V5CaddyIngress = {
id: string;
name: string;
host: string;
type: string;
status: string;
statusMessage: string | null;
canvasX: number;
canvasY: number;
};
export type V5ResourceConnection = {
id: string;
applicationIds: string[];
fromApplicationId: string;
toApplicationId: string;
portsByDirection: Record<string, string[]>;
};
export type V5DashboardProps = {
flux: FluxStatus | null;
currentTeam?: {
id: number;
} | null;
applications?: V5Application[];
caddyIngresses?: V5CaddyIngress[];
resourceConnections?: V5ResourceConnection[];
nginxServers?: V5NginxServer[];
clusters?: V5Cluster[];
privateKeys?: V5PrivateKey[];
projects?: V5Project[];
selectedProjectUuid?: string | null;
selectedEnvironmentUuid?: string | null;
selectedApplicationUuid?: string | null;
};
export type SelectItemOption = {
label: string;
value: string;
};
@@ -0,0 +1 @@
declare module '*.css';
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title inertia>{{ config('app.name') }} v5</title>
@env('local')
<link rel="icon" href="{{ asset('coolify-logo-dev-transparent.png') }}" type="image/png" />
@else
<link rel="icon" href="{{ asset('coolify-logo.svg') }}" type="image/svg+xml" />
@endenv
@auth
<script type="text/javascript" src="{{ URL::asset('js/echo.js') }}"></script>
<script type="text/javascript" src="{{ URL::asset('js/pusher.js') }}"></script>
<script>
window.Pusher = Pusher;
const EchoConstructor = typeof Echo === 'function' ? Echo : Echo.default;
window.Echo = new EchoConstructor({
broadcaster: 'pusher',
cluster: "{{ config('constants.pusher.host') }}" || window.location.hostname,
key: "{{ config('constants.pusher.app_key') }}" || 'coolify',
wsHost: "{{ config('constants.pusher.host') }}" || window.location.hostname,
wsPort: "{{ getRealtime() }}",
wssPort: "{{ getRealtime() }}",
forceTLS: false,
encrypted: true,
enableStats: false,
enableLogging: @json(app()->environment('local')),
enabledTransports: ['ws', 'wss'],
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
});
</script>
@endauth
@php
$viteHotFile = public_path('hot');
$viteDevServerUrl = null;
if (file_exists($viteHotFile)) {
$viteHotUrl = trim(file_get_contents($viteHotFile));
$viteScheme = parse_url($viteHotUrl, PHP_URL_SCHEME) ?: request()->getScheme();
$vitePort = parse_url($viteHotUrl, PHP_URL_PORT) ?: 5173;
$viteHost = request()->getHost();
$viteDevServerUrl = "{$viteScheme}://{$viteHost}:{$vitePort}";
}
@endphp
@if ($viteDevServerUrl)
<script type="module">
import RefreshRuntime from '{{ $viteDevServerUrl }}/@react-refresh'
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="{{ $viteDevServerUrl }}/@@vite/client"></script>
<script type="module" src="{{ $viteDevServerUrl }}/resources/js/v5/app.tsx"></script>
@else
@viteReactRefresh
@vite('resources/js/v5/app.tsx')
@endif
<x-inertia::head />
</head>
<body class="overflow-y-scroll">
<x-inertia::app id="v5-app" />
</body>
</html>