mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 10:18:20 +00:00
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:
1 parent
1440d35750
commit
76030a30d4
237 files changed
+288
-7079
No files matched your search
@@ -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.
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user