mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 18:18:29 +00:00
feat(v5): add server reconciliation and canvas APIs
Split V5 dashboard behavior into domain controllers and policies, add agent token rotation/revocation, status reconciliation jobs, ingress firewall syncing, and canvas connection APIs. Add migrations for V5 status tracking, server capabilities, resource connection aliases, and revoked agent tokens.
This commit is contained in:
1 parent
df854feee8
commit
6ae45684f9
147 files changed
+22444
-10407
No files matched your search
+394
-247
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
import { AppNavbar } from '@/components/app-navbar';
|
||||
import { CanvasNotice } from '@/components/canvas/canvas-notice';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -25,8 +26,9 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { csrfToken } from '@/lib/csrf';
|
||||
import { apiRequest } from '@/lib/api';
|
||||
import { usePendingIds } from '@/lib/use-pending-ids';
|
||||
import { useTeamChannel } from '@/lib/use-team-channel';
|
||||
import type { V5Cluster, V5DashboardProps, V5Server } from '@/types';
|
||||
|
||||
type ClusterFormErrors = {
|
||||
@@ -90,11 +92,13 @@ type DeleteServerResponse = {
|
||||
type CooldLogsResponse = {
|
||||
output: string;
|
||||
fetchedAt: string;
|
||||
source: 'flux' | 'ssh';
|
||||
};
|
||||
|
||||
type CorrosionTablesResponse = {
|
||||
output: string;
|
||||
fetchedAt: string;
|
||||
source: 'flux' | 'ssh';
|
||||
};
|
||||
|
||||
type FirewallRule = {
|
||||
@@ -127,16 +131,28 @@ type BootstrapServerResponse = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type ServerSshCheck = {
|
||||
status: string;
|
||||
output: string;
|
||||
checkedAt: string;
|
||||
type ServerConnectionNotice = {
|
||||
message: string;
|
||||
description: string;
|
||||
variant: 'danger' | 'success';
|
||||
};
|
||||
|
||||
type V5ClusterUpdatedEvent = {
|
||||
cluster: V5Cluster | null;
|
||||
};
|
||||
|
||||
type ParsedBootstrapLogSummary = {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: 'success' | 'muted';
|
||||
};
|
||||
|
||||
type ParsedBootstrapLogs = {
|
||||
summary: ParsedBootstrapLogSummary[];
|
||||
visibleOutput: string;
|
||||
rawOutput: string;
|
||||
};
|
||||
|
||||
function formatCorrosionCell(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'null';
|
||||
@@ -163,6 +179,147 @@ function parseCorrosionTables(output: string): CorrosionTableDump | null {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonValueToString(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function initialJsonEnd(output: string): number | null {
|
||||
const trimmedStart = output.search(/\S/);
|
||||
|
||||
if (trimmedStart === -1 || output[trimmedStart] !== '{') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let isEscaped = false;
|
||||
|
||||
for (let index = trimmedStart; index < output.length; index += 1) {
|
||||
const character = output[index];
|
||||
|
||||
if (isEscaped) {
|
||||
isEscaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\\') {
|
||||
isEscaped = inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '{') {
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
if (character === '}') {
|
||||
depth -= 1;
|
||||
}
|
||||
|
||||
if (depth === 0) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hideRawBootstrapPlan(output: string): string {
|
||||
const visibleLines: string[] = [];
|
||||
let isSkippingPlan = false;
|
||||
|
||||
output.split('\n').forEach((line) => {
|
||||
if (line.trim() === 'Plan:') {
|
||||
isSkippingPlan = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSkippingPlan) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine === '' || trimmedLine.startsWith('[') || line.startsWith(' ')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSkippingPlan = false;
|
||||
}
|
||||
|
||||
visibleLines.push(line);
|
||||
});
|
||||
|
||||
return visibleLines.join('\n').trim();
|
||||
}
|
||||
|
||||
function parseBootstrapOutput(output: string | null): ParsedBootstrapLogs {
|
||||
const rawOutput = output?.trim() || 'No install logs captured yet.';
|
||||
const jsonEnd = initialJsonEnd(rawOutput);
|
||||
|
||||
if (jsonEnd === null) {
|
||||
return {
|
||||
summary: [],
|
||||
visibleOutput: rawOutput,
|
||||
rawOutput,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawOutput.slice(0, jsonEnd)) as {
|
||||
results?: Array<{
|
||||
action?: { action?: unknown; host?: unknown };
|
||||
status?: unknown;
|
||||
detail?: unknown;
|
||||
}>;
|
||||
verified?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const summary = [
|
||||
...(parsed.results ?? []).map((result) => ({
|
||||
label: jsonValueToString(result.action?.action ?? result.action?.host ?? 'Action'),
|
||||
value: `${jsonValueToString(result.status)}${result.detail ? ` · ${jsonValueToString(result.detail)}` : ''}`,
|
||||
tone: result.status === 'ok' ? ('success' as const) : ('muted' as const),
|
||||
})),
|
||||
...(parsed.verified ?? []).map((node) => ({
|
||||
label: `Verified ${jsonValueToString(node.host)}`,
|
||||
value: [
|
||||
`status ${jsonValueToString(node.status)}`,
|
||||
`wg ${jsonValueToString(node.wireguard_ip)}`,
|
||||
`peers ${jsonValueToString(node.peer_count)}`,
|
||||
].join(' · '),
|
||||
tone: node.status === 'ok' ? ('success' as const) : ('muted' as const),
|
||||
})),
|
||||
];
|
||||
const remainingOutput = rawOutput.slice(jsonEnd).trim();
|
||||
|
||||
return {
|
||||
summary,
|
||||
visibleOutput: hideRawBootstrapPlan(remainingOutput),
|
||||
rawOutput,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
summary: [],
|
||||
visibleOutput: rawOutput,
|
||||
rawOutput,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status
|
||||
.split(/[-_\s]+/)
|
||||
@@ -187,24 +344,6 @@ function statusBadgeClass(status: string): string {
|
||||
return 'border-border bg-muted/40 text-muted-foreground';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const clusterDefaults = {
|
||||
wireguardInterface: 'wg0',
|
||||
wireguardManagementPool: '100.64.0.0/16',
|
||||
@@ -242,6 +381,18 @@ function formatDate(value: string | null): string {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function diagnosticsSourceLabel(source: 'flux' | 'ssh' | null): string {
|
||||
if (source === 'ssh') {
|
||||
return 'SSH';
|
||||
}
|
||||
|
||||
if (source === 'flux') {
|
||||
return 'Flux';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
export default function Clusters({
|
||||
flux,
|
||||
currentTeam = null,
|
||||
@@ -297,8 +448,9 @@ export default function Clusters({
|
||||
const [isServerSubmitting, setIsServerSubmitting] = useState(false);
|
||||
const [isServerUpdateSubmitting, setIsServerUpdateSubmitting] = useState(false);
|
||||
const checkingServers = usePendingIds<string>();
|
||||
const [sshChecks, setSshChecks] = useState<Record<string, ServerSshCheck>>({});
|
||||
const [visibleBootstrapLogs, setVisibleBootstrapLogs] = useState<Record<string, boolean>>({});
|
||||
const [serverConnectionNotice, setServerConnectionNotice] = useState<ServerConnectionNotice | null>(null);
|
||||
const [isBootstrapLogsDialogOpen, setIsBootstrapLogsDialogOpen] = useState(false);
|
||||
const [bootstrapLogsServerId, setBootstrapLogsServerId] = useState<string | null>(null);
|
||||
const bootstrappingServers = usePendingIds<string>();
|
||||
const [bootstrapServerError, setBootstrapServerError] = useState<string | null>(null);
|
||||
const deletingServers = usePendingIds<string>();
|
||||
@@ -314,12 +466,14 @@ export default function Clusters({
|
||||
const [cooldLogsServer, setCooldLogsServer] = useState<V5Server | null>(null);
|
||||
const [cooldLogsOutput, setCooldLogsOutput] = useState('');
|
||||
const [cooldLogsFetchedAt, setCooldLogsFetchedAt] = useState<string | null>(null);
|
||||
const [cooldLogsSource, setCooldLogsSource] = useState<'flux' | 'ssh' | null>(null);
|
||||
const [cooldLogsError, setCooldLogsError] = useState<string | null>(null);
|
||||
const [isLoadingCooldLogs, setIsLoadingCooldLogs] = useState(false);
|
||||
const [isCorrosionTablesDialogOpen, setIsCorrosionTablesDialogOpen] = useState(false);
|
||||
const [corrosionTablesServer, setCorrosionTablesServer] = useState<V5Server | null>(null);
|
||||
const [corrosionTablesOutput, setCorrosionTablesOutput] = useState('');
|
||||
const [corrosionTablesFetchedAt, setCorrosionTablesFetchedAt] = useState<string | null>(null);
|
||||
const [corrosionTablesSource, setCorrosionTablesSource] = useState<'flux' | 'ssh' | null>(null);
|
||||
const [corrosionTablesError, setCorrosionTablesError] = useState<string | null>(null);
|
||||
const [isLoadingCorrosionTables, setIsLoadingCorrosionTables] = useState(false);
|
||||
const [isFirewallRulesDialogOpen, setIsFirewallRulesDialogOpen] = useState(false);
|
||||
@@ -340,60 +494,23 @@ export default function Clusters({
|
||||
const initializedServers = selectedCluster?.servers.filter((server) => server.lastBootstrappedAt !== null) ?? [];
|
||||
const hasBootstrapInProgress =
|
||||
selectedCluster?.servers.some((server) => ['queued', 'running'].includes(server.lastBootstrapStatus ?? '')) ?? false;
|
||||
const bootstrapLogsServer = useMemo(
|
||||
() => clusterList.flatMap((cluster) => cluster.servers).find((server) => server.id === bootstrapLogsServerId) ?? null,
|
||||
[bootstrapLogsServerId, clusterList],
|
||||
);
|
||||
const parsedBootstrapLogs = parseBootstrapOutput(bootstrapLogsServer?.lastBootstrapOutput ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTeam) {
|
||||
useTeamChannel(currentTeam?.id ?? null, '.v5.cluster.updated', (payload) => {
|
||||
const event = payload as V5ClusterUpdatedEvent;
|
||||
|
||||
if (!event.cluster) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
let attempts = 0;
|
||||
const channelName = `team.${currentTeam.id}`;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
|
||||
if (!window.Echo) {
|
||||
if (attempts === 1) {
|
||||
console.debug('Waiting for window.Echo before subscribing to cluster updates');
|
||||
}
|
||||
|
||||
if (attempts >= 20) {
|
||||
window.clearInterval(interval);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(interval);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = window.Echo.private(channelName);
|
||||
|
||||
channel.subscribed?.(() => console.debug(`Subscribed to private-${channelName} for cluster updates`));
|
||||
channel.error?.((error) => console.error(`Subscription error on private-${channelName}`, error));
|
||||
channel.listen('.v5.cluster.updated', (payload) => {
|
||||
const event = payload as V5ClusterUpdatedEvent;
|
||||
|
||||
if (!event.cluster) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClusterList((currentClusters) =>
|
||||
currentClusters.map((cluster) => (cluster.id === event.cluster?.id ? event.cluster : cluster)),
|
||||
);
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`);
|
||||
};
|
||||
}, [currentTeam]);
|
||||
setClusterList((currentClusters) =>
|
||||
currentClusters.map((cluster) => (cluster.id === event.cluster?.id ? event.cluster : cluster)),
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCluster || !hasBootstrapInProgress) {
|
||||
@@ -407,15 +524,9 @@ export default function Clusters({
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}`, { method: 'GET' }).catch(() => null);
|
||||
|
||||
if (!response.ok || isCancelled) {
|
||||
if (!response?.ok || isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -441,15 +552,9 @@ export default function Clusters({
|
||||
setIsSubmitting(true);
|
||||
setErrors({});
|
||||
|
||||
const response = await fetch('/v5/clusters', {
|
||||
const response = await apiRequest('/v5/clusters', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
name,
|
||||
description: description.trim() === '' ? null : description,
|
||||
wireguard_interface: wireguardInterface,
|
||||
@@ -471,10 +576,10 @@ export default function Clusters({
|
||||
builder_cpu_quota: builderCpuQuota,
|
||||
builder_memory_max: builderMemoryMax,
|
||||
builder_timeout_secs: Number(builderTimeoutSecs),
|
||||
}),
|
||||
});
|
||||
},
|
||||
}).catch(() => null);
|
||||
|
||||
if (response.status === 422) {
|
||||
if (response?.status === 422) {
|
||||
const payload = (await response.json()) as {
|
||||
errors?: ClusterFormErrors;
|
||||
};
|
||||
@@ -484,7 +589,7 @@ export default function Clusters({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
setErrors({
|
||||
name: ['Unable to create this cluster. Please try again.'],
|
||||
});
|
||||
@@ -517,15 +622,9 @@ export default function Clusters({
|
||||
setIsServerSubmitting(true);
|
||||
setServerErrors({});
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers`, {
|
||||
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
name: serverName,
|
||||
host: serverHost,
|
||||
ssh_user: serverSshUser,
|
||||
@@ -540,10 +639,10 @@ export default function Clusters({
|
||||
wireguard_listen_port_override:
|
||||
wireguardListenPortOverride.trim() === '' ? null : Number(wireguardListenPortOverride),
|
||||
wireguard_endpoint_override: wireguardEndpointOverride.trim() === '' ? null : wireguardEndpointOverride,
|
||||
}),
|
||||
});
|
||||
},
|
||||
}).catch(() => null);
|
||||
|
||||
if (response.status === 422) {
|
||||
if (response?.status === 422) {
|
||||
const payload = (await response.json()) as {
|
||||
errors?: ServerFormErrors;
|
||||
};
|
||||
@@ -553,7 +652,7 @@ export default function Clusters({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
setServerErrors({
|
||||
name: ['Unable to add this server. Please try again.'],
|
||||
});
|
||||
@@ -582,24 +681,18 @@ export default function Clusters({
|
||||
setIsServerUpdateSubmitting(true);
|
||||
setEditServerErrors({});
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`, {
|
||||
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${editingServer.id}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
builder_enabled: editServerBuilderEnabled,
|
||||
ingress_enabled: editServerIngressEnabled,
|
||||
ingress_type: editServerIngressEnabled ? editServerIngressType : null,
|
||||
builder_capacity: Number(editServerBuilderCapacity),
|
||||
builder_cpu_quota: editServerBuilderCpuQuota,
|
||||
}),
|
||||
});
|
||||
},
|
||||
}).catch(() => null);
|
||||
|
||||
if (response.status === 422) {
|
||||
if (response?.status === 422) {
|
||||
const payload = (await response.json()) as {
|
||||
errors?: ServerFormErrors;
|
||||
};
|
||||
@@ -609,8 +702,8 @@ export default function Clusters({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
if (!response?.ok) {
|
||||
const payload = (await response?.json().catch(() => null)) as { message?: string } | null;
|
||||
|
||||
setEditServerErrors({
|
||||
builder_capacity: [payload?.message ?? 'Unable to update this server. Please try again.'],
|
||||
@@ -637,23 +730,20 @@ export default function Clusters({
|
||||
|
||||
checkingServers.start(server.id);
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`, {
|
||||
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/check`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
});
|
||||
}).catch(() => null);
|
||||
const payload = (await response?.json().catch(() => null)) as (CheckServerResponse & { message?: string }) | null;
|
||||
|
||||
if (response.ok) {
|
||||
const payload = (await response.json()) as CheckServerResponse;
|
||||
setSshChecks((currentChecks) => ({
|
||||
...currentChecks,
|
||||
[server.id]: payload,
|
||||
}));
|
||||
}
|
||||
setServerConnectionNotice({
|
||||
message: response?.ok
|
||||
? `Connection check for ${server.name}: ${payload?.status ?? 'unknown'}`
|
||||
: `Connection check failed for ${server.name}`,
|
||||
description: response?.ok
|
||||
? (payload?.output ?? 'No output returned.')
|
||||
: (payload?.message ?? 'Unable to check server connection.'),
|
||||
variant: response?.ok ? 'success' : 'danger',
|
||||
});
|
||||
|
||||
checkingServers.finish(server.id);
|
||||
}
|
||||
@@ -665,17 +755,12 @@ export default function Clusters({
|
||||
|
||||
bootstrappingServers.start(server.id);
|
||||
setBootstrapServerError(null);
|
||||
openBootstrapLogs(server);
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`, {
|
||||
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/bootstrap`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
});
|
||||
const payload = (await response.json()) as BootstrapServerResponse;
|
||||
}).catch(() => null);
|
||||
const payload = ((await response?.json().catch(() => null)) ?? {}) as BootstrapServerResponse;
|
||||
|
||||
if (payload.cluster) {
|
||||
setClusterList((currentClusters) =>
|
||||
@@ -683,13 +768,17 @@ export default function Clusters({
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
setBootstrapServerError(payload.message ?? 'Unable to queue bootstrap for this server.');
|
||||
}
|
||||
|
||||
bootstrappingServers.finish(server.id);
|
||||
}
|
||||
|
||||
function openBootstrapLogs(server: V5Server): void {
|
||||
setBootstrapLogsServerId(server.id);
|
||||
setIsBootstrapLogsDialogOpen(true);
|
||||
}
|
||||
|
||||
async function loadCooldLogs(server: V5Server): Promise<void> {
|
||||
if (!selectedCluster) {
|
||||
@@ -702,18 +791,15 @@ export default function Clusters({
|
||||
setCooldLogsError(null);
|
||||
setCooldLogsOutput('');
|
||||
setCooldLogsFetchedAt(null);
|
||||
setCooldLogsSource(null);
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/coold-logs?tail=200`, {
|
||||
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/coold-logs?tail=200`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as CooldLogsResponse & { message?: string } | null;
|
||||
const payload = (await response?.json().catch(() => null)) as CooldLogsResponse & { message?: string } | null;
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
setCooldLogsError(payload?.message ?? 'Unable to load coold logs.');
|
||||
setIsLoadingCooldLogs(false);
|
||||
|
||||
@@ -722,6 +808,7 @@ export default function Clusters({
|
||||
|
||||
setCooldLogsOutput(payload?.output ?? '');
|
||||
setCooldLogsFetchedAt(payload?.fetchedAt ?? null);
|
||||
setCooldLogsSource(payload?.source ?? null);
|
||||
setIsLoadingCooldLogs(false);
|
||||
}
|
||||
|
||||
@@ -736,18 +823,15 @@ export default function Clusters({
|
||||
setCorrosionTablesError(null);
|
||||
setCorrosionTablesOutput('');
|
||||
setCorrosionTablesFetchedAt(null);
|
||||
setCorrosionTablesSource(null);
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/corrosion-tables?limit=200`, {
|
||||
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/corrosion-tables?limit=200`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as CorrosionTablesResponse & { message?: string } | null;
|
||||
const payload = (await response?.json().catch(() => null)) as CorrosionTablesResponse & { message?: string } | null;
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
setCorrosionTablesError(payload?.message ?? 'Unable to load Corrosion tables.');
|
||||
setIsLoadingCorrosionTables(false);
|
||||
|
||||
@@ -756,6 +840,7 @@ export default function Clusters({
|
||||
|
||||
setCorrosionTablesOutput(payload?.output ?? '');
|
||||
setCorrosionTablesFetchedAt(payload?.fetchedAt ?? null);
|
||||
setCorrosionTablesSource(payload?.source ?? null);
|
||||
setIsLoadingCorrosionTables(false);
|
||||
}
|
||||
|
||||
@@ -772,17 +857,13 @@ export default function Clusters({
|
||||
setFirewallRules([]);
|
||||
setFirewallRulesFetchedAt(null);
|
||||
|
||||
const response = await fetch(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/firewall-rules`, {
|
||||
const response = await apiRequest(`/v5/clusters/${selectedCluster.id}/servers/${server.id}/firewall-rules`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as FirewallRulesResponse & { message?: string } | null;
|
||||
const payload = (await response?.json().catch(() => null)) as FirewallRulesResponse & { message?: string } | null;
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
setFirewallRulesError(payload?.message ?? 'Unable to load firewall rules.');
|
||||
setIsLoadingFirewallRules(false);
|
||||
|
||||
@@ -820,25 +901,32 @@ export default function Clusters({
|
||||
|
||||
async function deleteServer(cluster: V5Cluster, server: V5Server): Promise<void> {
|
||||
deletingServers.start(server.id);
|
||||
setDeleteClusterError(null);
|
||||
|
||||
const response = await fetch(`/v5/clusters/${cluster.id}/servers/${server.id}`, {
|
||||
const response = await apiRequest(`/v5/clusters/${cluster.id}/servers/${server.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
if (response.ok) {
|
||||
const payload = (await response.json()) as DeleteServerResponse;
|
||||
if (!response?.ok) {
|
||||
const payload = (await response?.json().catch(() => null)) as { message?: string } | null;
|
||||
|
||||
setClusterList((currentClusters) =>
|
||||
currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)),
|
||||
setDeleteClusterError(
|
||||
payload?.message ??
|
||||
(response?.status === 422
|
||||
? 'Delete or move applications from this server before deleting it.'
|
||||
: 'Unable to delete this server. Please try again.'),
|
||||
);
|
||||
deletingServers.finish(server.id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as DeleteServerResponse;
|
||||
|
||||
setClusterList((currentClusters) =>
|
||||
currentClusters.map((cluster) => (cluster.id === payload.cluster.id ? payload.cluster : cluster)),
|
||||
);
|
||||
|
||||
deletingServers.finish(server.id);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setClusterPendingDelete(null);
|
||||
@@ -866,16 +954,11 @@ export default function Clusters({
|
||||
setIsDeletingCluster(true);
|
||||
setDeleteClusterError(null);
|
||||
|
||||
const response = await fetch(`/v5/clusters/${cluster.id}`, {
|
||||
const response = await apiRequest(`/v5/clusters/${cluster.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
if (response.status === 422) {
|
||||
if (response?.status === 422) {
|
||||
const payload = (await response.json()) as { message?: string };
|
||||
setDeleteClusterError(payload.message ?? 'Only empty clusters can be deleted.');
|
||||
setIsDeletingCluster(false);
|
||||
@@ -883,14 +966,14 @@ export default function Clusters({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response?.ok) {
|
||||
setDeleteClusterError('Unable to delete this cluster. Please try again.');
|
||||
setIsDeletingCluster(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextClusters = clusterList.filter((cluster) => cluster.id !== selectedCluster.id);
|
||||
const nextClusters = clusterList.filter((remainingCluster) => remainingCluster.id !== cluster.id);
|
||||
|
||||
setClusterList(nextClusters);
|
||||
setSelectedClusterId(nextClusters[0]?.id ?? '');
|
||||
@@ -969,11 +1052,8 @@ export default function Clusters({
|
||||
const isBootstrappingServer = bootstrappingServers.has(server.id) || isBootstrapInProgress;
|
||||
const isDeletingServer = deletingServers.has(server.id);
|
||||
const isServerInitialized = server.lastBootstrappedAt !== null;
|
||||
const latestSshCheck = sshChecks[server.id] ?? null;
|
||||
const hasBootstrapLogs = server.lastBootstrapOutput !== null && server.lastBootstrapOutput.trim() !== '';
|
||||
const canShowBootstrapLogs = hasBootstrapLogs || isBootstrapInProgress;
|
||||
const isBootstrapLogVisible =
|
||||
isBootstrapInProgress || (canShowBootstrapLogs && (visibleBootstrapLogs[server.id] ?? false));
|
||||
|
||||
return (
|
||||
<article key={server.id} className="rounded-lg border border-border bg-background p-4">
|
||||
@@ -1035,20 +1115,8 @@ export default function Clusters({
|
||||
{isCheckingServer ? 'Checking...' : 'Check connection'}
|
||||
</DropdownMenuItem>
|
||||
{canShowBootstrapLogs ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isBootstrapInProgress}
|
||||
onClick={() =>
|
||||
setVisibleBootstrapLogs((currentLogs) => ({
|
||||
...currentLogs,
|
||||
[server.id]: !isBootstrapLogVisible,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{isBootstrapInProgress
|
||||
? 'Install logs shown'
|
||||
: isBootstrapLogVisible
|
||||
? 'Hide install logs'
|
||||
: 'Show install logs'}
|
||||
<DropdownMenuItem onClick={() => openBootstrapLogs(server)}>
|
||||
View install logs
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onClick={() => void loadCooldLogs(server)}>
|
||||
@@ -1113,37 +1181,6 @@ export default function Clusters({
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{latestSshCheck ? (
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="font-medium text-foreground">Latest SSH check: {latestSshCheck.status}</span>
|
||||
<span className="text-muted-foreground">{formatDate(latestSshCheck.checkedAt)}</span>
|
||||
</div>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-background p-2 text-muted-foreground">
|
||||
{latestSshCheck.output}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isBootstrapLogVisible ? (
|
||||
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="font-medium text-foreground">
|
||||
Install logs
|
||||
{server.lastBootstrapStatus ? `: ${server.lastBootstrapStatus}` : ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{formatDate(server.lastBootstrapRanAt)}</span>
|
||||
</div>
|
||||
{server.lastBootstrapOutput ? (
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-background p-2 text-muted-foreground">
|
||||
{server.lastBootstrapOutput}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="mt-2 text-muted-foreground">No install logs captured yet.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1161,6 +1198,15 @@ export default function Clusters({
|
||||
selectedEnvironmentUuid={selectedEnvironmentUuid}
|
||||
/>
|
||||
|
||||
{serverConnectionNotice ? (
|
||||
<CanvasNotice
|
||||
message={serverConnectionNotice.message}
|
||||
description={serverConnectionNotice.description}
|
||||
variant={serverConnectionNotice.variant}
|
||||
onDismiss={() => setServerConnectionNotice(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<main className="flex min-h-dvh overflow-visible px-4 pt-16 lg:h-full lg:min-h-0 lg:overflow-hidden lg:px-6">
|
||||
<section className="flex w-full flex-col gap-4 py-4 lg:min-h-0 lg:py-6">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
@@ -1487,6 +1533,7 @@ export default function Clusters({
|
||||
setIsDeleteDialogOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setDeleteClusterError(null);
|
||||
setClusterPendingDelete(null);
|
||||
setServerPendingDelete(null);
|
||||
}
|
||||
@@ -1501,6 +1548,14 @@ export default function Clusters({
|
||||
: `Delete cluster ${clusterPendingDelete?.name ?? ''}? This cannot be undone.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteClusterError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{deleteClusterError}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -2027,6 +2082,82 @@ export default function Clusters({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isBootstrapLogsDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsBootstrapLogsDialogOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setBootstrapLogsServerId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install logs</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bootstrap output for {bootstrapLogsServer?.name ?? 'this server'}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>
|
||||
Status: {bootstrapLogsServer?.lastBootstrapStatus ?? 'unknown'}
|
||||
{['queued', 'running'].includes(bootstrapLogsServer?.lastBootstrapStatus ?? '')
|
||||
? ' · Auto-refreshing while bootstrap runs'
|
||||
: ''}
|
||||
</p>
|
||||
<p>{formatDate(bootstrapLogsServer?.lastBootstrapRanAt ?? null)}</p>
|
||||
</div>
|
||||
|
||||
{parsedBootstrapLogs.summary.length > 0 ? (
|
||||
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<p className="mb-3 text-sm font-medium text-foreground">Action results</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{parsedBootstrapLogs.summary.map((item, index) => (
|
||||
<div
|
||||
key={`${item.label}-${index}`}
|
||||
className="rounded-md border border-border bg-background p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm font-medium text-foreground">{item.label}</p>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||
item.tone === 'success'
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{item.tone === 'success' ? 'OK' : 'Info'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{parsedBootstrapLogs.visibleOutput ? (
|
||||
<pre className="max-h-[70dvh] max-w-full overflow-auto whitespace-pre-wrap rounded-lg border border-border bg-black p-4 font-mono text-xs leading-relaxed text-white">
|
||||
{parsedBootstrapLogs.visibleOutput}
|
||||
</pre>
|
||||
) : null}
|
||||
|
||||
{parsedBootstrapLogs.summary.length > 0 ? (
|
||||
<details className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<summary className="cursor-pointer text-sm font-medium text-foreground">
|
||||
Raw JSON output
|
||||
</summary>
|
||||
<pre className="mt-3 max-h-80 max-w-full overflow-auto whitespace-pre-wrap rounded-md bg-black p-3 font-mono text-xs leading-relaxed text-white">
|
||||
{parsedBootstrapLogs.rawOutput}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isCooldLogsDialogOpen}
|
||||
@@ -2037,6 +2168,7 @@ export default function Clusters({
|
||||
setCooldLogsServer(null);
|
||||
setCooldLogsOutput('');
|
||||
setCooldLogsFetchedAt(null);
|
||||
setCooldLogsSource(null);
|
||||
setCooldLogsError(null);
|
||||
}
|
||||
}}
|
||||
@@ -2051,9 +2183,16 @@ export default function Clusters({
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cooldLogsFetchedAt ? `Fetched ${formatDate(cooldLogsFetchedAt)}` : 'Last 200 lines'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cooldLogsFetchedAt ? `Fetched ${formatDate(cooldLogsFetchedAt)}` : 'Last 200 lines'}
|
||||
</p>
|
||||
{cooldLogsSource ? (
|
||||
<span className="rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
Source: {diagnosticsSourceLabel(cooldLogsSource)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{cooldLogsServer ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -2089,6 +2228,7 @@ export default function Clusters({
|
||||
setCorrosionTablesServer(null);
|
||||
setCorrosionTablesOutput('');
|
||||
setCorrosionTablesFetchedAt(null);
|
||||
setCorrosionTablesSource(null);
|
||||
setCorrosionTablesError(null);
|
||||
}
|
||||
}}
|
||||
@@ -2103,11 +2243,18 @@ export default function Clusters({
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{corrosionTablesFetchedAt
|
||||
? `Fetched ${formatDate(corrosionTablesFetchedAt)}`
|
||||
: 'First 200 rows per table'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{corrosionTablesFetchedAt
|
||||
? `Fetched ${formatDate(corrosionTablesFetchedAt)}`
|
||||
: 'First 200 rows per table'}
|
||||
</p>
|
||||
{corrosionTablesSource ? (
|
||||
<span className="rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
Source: {diagnosticsSourceLabel(corrosionTablesSource)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{corrosionTablesServer ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+431
-1644
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,11 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { useEffect, useState } from '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 & {
|
||||
@@ -19,24 +20,6 @@ type RealtimeTestEvent = {
|
||||
sentAt: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLogPayload(payload: unknown): string {
|
||||
if (typeof payload === 'string') {
|
||||
return payload;
|
||||
@@ -50,65 +33,30 @@ export default function RealtimeTest({ currentTeam, flux, projects = [], selecte
|
||||
const [isBroadcasting, setIsBroadcasting] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
|
||||
function addLog(label: string, payload?: unknown): void {
|
||||
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.');
|
||||
|
||||
return;
|
||||
}
|
||||
}, [currentTeam, addLog]);
|
||||
|
||||
let isCancelled = false;
|
||||
let attempts = 0;
|
||||
const channelName = `team.${currentTeam.id}`;
|
||||
useTeamChannel(
|
||||
currentTeam?.id ?? null,
|
||||
'.v5.realtime.test',
|
||||
(payload) => {
|
||||
const event = payload as RealtimeTestEvent;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
attempts += 1;
|
||||
|
||||
if (!window.Echo) {
|
||||
if (attempts === 1) {
|
||||
addLog('Waiting for window.Echo...');
|
||||
}
|
||||
|
||||
if (attempts >= 20) {
|
||||
window.clearInterval(interval);
|
||||
addLog('window.Echo was not available after 10 seconds.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(interval);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
addLog(`Subscribing to private-${channelName}`);
|
||||
const channel = window.Echo.private(channelName);
|
||||
|
||||
channel.subscribed?.(() => addLog(`Subscribed to private-${channelName}`));
|
||||
channel.error?.((error: unknown) => addLog(`Subscription error on private-${channelName}`, error));
|
||||
channel.listen('.v5.realtime.test', (payload) => {
|
||||
const event = payload as RealtimeTestEvent;
|
||||
|
||||
addLog('Received .v5.realtime.test', event);
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.Echo?.leave?.(channelName) ?? window.Echo?.leaveChannel?.(`private-${channelName}`);
|
||||
};
|
||||
}, [currentTeam]);
|
||||
addLog('Received .v5.realtime.test', event);
|
||||
},
|
||||
{ onDebug: addLog, onError: addLog },
|
||||
);
|
||||
|
||||
async function broadcastTestEvent(): Promise<void> {
|
||||
setIsBroadcasting(true);
|
||||
|
||||
@@ -7,25 +7,44 @@ 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<void> {
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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', 'selectedProjectUuid', 'selectedEnvironmentUuid'],
|
||||
only: [
|
||||
'applications',
|
||||
'caddyIngresses',
|
||||
'resourceConnections',
|
||||
'nginxServers',
|
||||
'selectedProjectUuid',
|
||||
'selectedEnvironmentUuid',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,10 +84,21 @@ export function AppNavbar({
|
||||
|
||||
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(refreshCurrentPageSelection);
|
||||
void persistSelection(nextProjectUuid, nextEnvironmentUuid).then((persisted) => {
|
||||
if (persisted) {
|
||||
refreshCurrentPageSelection();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectUuid(previousProjectUuid);
|
||||
setEnvironmentUuid(previousEnvironmentUuid);
|
||||
});
|
||||
}
|
||||
|
||||
function selectEnvironment(nextEnvironmentUuid: string | null): void {
|
||||
@@ -76,8 +106,18 @@ export function AppNavbar({
|
||||
return;
|
||||
}
|
||||
|
||||
const previousEnvironmentUuid = environmentUuid;
|
||||
|
||||
setEnvironmentUuid(nextEnvironmentUuid);
|
||||
void persistSelection(projectUuid, nextEnvironmentUuid).then(refreshCurrentPageSelection);
|
||||
void persistSelection(projectUuid, nextEnvironmentUuid).then((persisted) => {
|
||||
if (persisted) {
|
||||
refreshCurrentPageSelection();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setEnvironmentUuid(previousEnvironmentUuid);
|
||||
});
|
||||
}
|
||||
|
||||
const projectItems: SelectItemOption[] = projects.map((project) => ({
|
||||
|
||||
@@ -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,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,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,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,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,314 @@
|
||||
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 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> => {
|
||||
const portsByDirection = Object.fromEntries(
|
||||
Object.entries(pruneConnectionPortsByDirection(updatedConnection)).map(([direction, ports]) => [
|
||||
direction,
|
||||
ports.map((port) => Number(port)).filter((port) => Number.isInteger(port)),
|
||||
]),
|
||||
);
|
||||
|
||||
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: portsByDirection },
|
||||
});
|
||||
|
||||
return parseConnectionResponse(response, 'Could not save allowed ports.');
|
||||
},
|
||||
fallbackErrorMessage: 'Could not save allowed ports.',
|
||||
notify,
|
||||
onSuccess: replaceConnection,
|
||||
});
|
||||
},
|
||||
[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,
|
||||
portsByDirection: pruneConnectionPortsByDirection(connection, fromApplicationId, toApplicationId),
|
||||
};
|
||||
|
||||
void persistConnectionPorts(updatedConnection, connection);
|
||||
},
|
||||
[persistConnectionPorts],
|
||||
);
|
||||
|
||||
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,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,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,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]);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export type V5Server = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
type: string;
|
||||
status: string;
|
||||
capabilities: string[];
|
||||
builderEnabled: boolean;
|
||||
@@ -23,7 +22,8 @@ export type V5Server = {
|
||||
wireguardEndpointOverride: string | null;
|
||||
wireguardManagementIp: string | null;
|
||||
wireguardPublicKey: string | null;
|
||||
containerSubnets: Record<string, string> | string[];
|
||||
/** 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;
|
||||
@@ -105,6 +105,8 @@ export type V5Application = {
|
||||
internalPort: number | null;
|
||||
domains: string[];
|
||||
meshFqdn: string;
|
||||
projectUuid: string | null;
|
||||
environmentUuid: string | null;
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
};
|
||||
@@ -113,6 +115,7 @@ export type V5CaddyIngress = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
type: string;
|
||||
status: string;
|
||||
statusMessage: string | null;
|
||||
canvasX: number;
|
||||
@@ -122,7 +125,7 @@ export type V5CaddyIngress = {
|
||||
|
||||
export type V5ResourceConnection = {
|
||||
id: string;
|
||||
applicationIds: [string, string];
|
||||
applicationIds: string[];
|
||||
fromApplicationId: string;
|
||||
toApplicationId: string;
|
||||
portsByDirection: Record<string, string[]>;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
forceTLS: false,
|
||||
encrypted: true,
|
||||
enableStats: false,
|
||||
enableLogging: true,
|
||||
enableLogging: @json(app()->environment('local')),
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
disabledTransports: ['sockjs', 'xhr_streaming', 'xhr_polling'],
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user