mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 22:14:53 +00:00
feat(v5): add dashboard ingress routing controls
Add V5 dashboard controls and validation for enabling server-backed app ingress, track generic ingress type/status fields, and improve Flux dispatch timeout/error handling.
This commit is contained in:
1 parent
ad0bcc8a4a
commit
1c091e9e36
22 files changed
+1120
-287
No files matched your search
@@ -64,6 +64,7 @@ type ServerFormErrors = {
|
||||
builder_enabled?: string[];
|
||||
builder_capacity?: string[];
|
||||
builder_cpu_quota?: string[];
|
||||
ingress_type?: string[];
|
||||
wireguard_listen_port_override?: string[];
|
||||
wireguard_endpoint_override?: string[];
|
||||
};
|
||||
@@ -139,6 +140,13 @@ const clusterDefaults = {
|
||||
builderTimeoutSecs: '1800',
|
||||
};
|
||||
|
||||
const ingressTypes = [
|
||||
{
|
||||
label: 'Caddy',
|
||||
value: 'caddy',
|
||||
},
|
||||
];
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (value === null) {
|
||||
return 'Never';
|
||||
@@ -188,6 +196,7 @@ export default function Clusters({
|
||||
const [serverNodeAddress, setServerNodeAddress] = useState('');
|
||||
const [serverBuilderEnabled, setServerBuilderEnabled] = useState(true);
|
||||
const [serverIngressEnabled, setServerIngressEnabled] = useState(false);
|
||||
const [serverIngressType, setServerIngressType] = useState('caddy');
|
||||
const [serverBuilderCapacity, setServerBuilderCapacity] = useState('2');
|
||||
const [serverBuilderCpuQuota, setServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
|
||||
const [wireguardListenPortOverride, setWireguardListenPortOverride] = useState('');
|
||||
@@ -196,6 +205,7 @@ export default function Clusters({
|
||||
const [editingServer, setEditingServer] = useState<V5Server | null>(null);
|
||||
const [editServerBuilderEnabled, setEditServerBuilderEnabled] = useState(true);
|
||||
const [editServerIngressEnabled, setEditServerIngressEnabled] = useState(false);
|
||||
const [editServerIngressType, setEditServerIngressType] = useState('caddy');
|
||||
const [editServerBuilderCapacity, setEditServerBuilderCapacity] = useState('2');
|
||||
const [editServerBuilderCpuQuota, setEditServerBuilderCpuQuota] = useState(clusterDefaults.builderCpuQuota);
|
||||
const [editServerErrors, setEditServerErrors] = useState<ServerFormErrors>({});
|
||||
@@ -422,6 +432,7 @@ export default function Clusters({
|
||||
node_address: serverNodeAddress.trim() === '' ? null : serverNodeAddress,
|
||||
builder_enabled: serverBuilderEnabled,
|
||||
ingress_enabled: serverIngressEnabled,
|
||||
ingress_type: serverIngressEnabled ? serverIngressType : null,
|
||||
builder_capacity: Number(serverBuilderCapacity),
|
||||
builder_cpu_quota: serverBuilderCpuQuota,
|
||||
wireguard_listen_port_override:
|
||||
@@ -480,6 +491,7 @@ export default function Clusters({
|
||||
body: JSON.stringify({
|
||||
builder_enabled: editServerBuilderEnabled,
|
||||
ingress_enabled: editServerIngressEnabled,
|
||||
ingress_type: editServerIngressEnabled ? editServerIngressType : null,
|
||||
builder_capacity: Number(editServerBuilderCapacity),
|
||||
builder_cpu_quota: editServerBuilderCpuQuota,
|
||||
}),
|
||||
@@ -496,8 +508,10 @@ export default function Clusters({
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
|
||||
setEditServerErrors({
|
||||
builder_capacity: ['Unable to update this server. Please try again.'],
|
||||
builder_capacity: [payload?.message ?? 'Unable to update this server. Please try again.'],
|
||||
});
|
||||
setIsServerUpdateSubmitting(false);
|
||||
|
||||
@@ -629,6 +643,7 @@ export default function Clusters({
|
||||
setEditingServer(server);
|
||||
setEditServerBuilderEnabled(server.builderEnabled);
|
||||
setEditServerIngressEnabled(server.ingressEnabled);
|
||||
setEditServerIngressType(server.ingressType ?? 'caddy');
|
||||
setEditServerBuilderCapacity(String(server.builderCapacity));
|
||||
setEditServerBuilderCpuQuota(server.builderCpuQuota);
|
||||
setEditServerErrors({});
|
||||
@@ -721,6 +736,8 @@ export default function Clusters({
|
||||
setSelectedPrivateKeyId('');
|
||||
setServerNodeAddress('');
|
||||
setServerBuilderEnabled(selectedCluster?.builderEnabled ?? true);
|
||||
setServerIngressEnabled(false);
|
||||
setServerIngressType('caddy');
|
||||
setServerBuilderCapacity(String(selectedCluster?.builderCapacity ?? 2));
|
||||
setServerBuilderCpuQuota(selectedCluster?.builderCpuQuota ?? clusterDefaults.builderCpuQuota);
|
||||
setWireguardListenPortOverride('');
|
||||
@@ -733,6 +750,7 @@ export default function Clusters({
|
||||
setEditingServer(null);
|
||||
setEditServerBuilderEnabled(true);
|
||||
setEditServerIngressEnabled(false);
|
||||
setEditServerIngressType('caddy');
|
||||
setEditServerBuilderCapacity('2');
|
||||
setEditServerBuilderCpuQuota(clusterDefaults.builderCpuQuota);
|
||||
setEditServerErrors({});
|
||||
@@ -1586,53 +1604,104 @@ export default function Clusters({
|
||||
<FieldError message={serverErrors.node_address?.[0]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Builder capacity</FieldLabel>
|
||||
<Input
|
||||
value={serverBuilderCapacity}
|
||||
onChange={(event) =>
|
||||
setServerBuilderCapacity(event.target.value)
|
||||
}
|
||||
inputMode="numeric"
|
||||
aria-invalid={serverErrors.builder_capacity ? true : undefined}
|
||||
/>
|
||||
<FieldError message={serverErrors.builder_capacity?.[0]} />
|
||||
</Field>
|
||||
<section className="rounded-lg border border-border bg-background/60 p-4 sm:col-span-2">
|
||||
<Field className="flex-row items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={serverBuilderEnabled}
|
||||
onChange={(event) =>
|
||||
setServerBuilderEnabled(event.target.checked)
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Enable builder on this server</FieldLabel>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Builder CPU quota</FieldLabel>
|
||||
<Input
|
||||
value={serverBuilderCpuQuota}
|
||||
onChange={(event) =>
|
||||
setServerBuilderCpuQuota(event.target.value)
|
||||
}
|
||||
placeholder="200%"
|
||||
aria-invalid={serverErrors.builder_cpu_quota ? true : undefined}
|
||||
/>
|
||||
<FieldError message={serverErrors.builder_cpu_quota?.[0]} />
|
||||
</Field>
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Builder capacity</FieldLabel>
|
||||
<Input
|
||||
value={serverBuilderCapacity}
|
||||
onChange={(event) =>
|
||||
setServerBuilderCapacity(event.target.value)
|
||||
}
|
||||
inputMode="numeric"
|
||||
aria-invalid={
|
||||
serverErrors.builder_capacity ? true : undefined
|
||||
}
|
||||
/>
|
||||
<FieldError message={serverErrors.builder_capacity?.[0]} />
|
||||
</Field>
|
||||
|
||||
<Field className="flex-row items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={serverBuilderEnabled}
|
||||
onChange={(event) =>
|
||||
setServerBuilderEnabled(event.target.checked)
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Enable builder on this server</FieldLabel>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Builder CPU quota</FieldLabel>
|
||||
<Input
|
||||
value={serverBuilderCpuQuota}
|
||||
onChange={(event) =>
|
||||
setServerBuilderCpuQuota(event.target.value)
|
||||
}
|
||||
placeholder="200%"
|
||||
aria-invalid={
|
||||
serverErrors.builder_cpu_quota ? true : undefined
|
||||
}
|
||||
/>
|
||||
<FieldError message={serverErrors.builder_cpu_quota?.[0]} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Field className="flex-row items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={serverIngressEnabled}
|
||||
onChange={(event) =>
|
||||
setServerIngressEnabled(event.target.checked)
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Enable Caddy ingress on this server</FieldLabel>
|
||||
</Field>
|
||||
<section className="rounded-lg border border-border bg-background/60 p-4 sm:col-span-2">
|
||||
<Field className="flex-row items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={serverIngressEnabled}
|
||||
onChange={(event) =>
|
||||
setServerIngressEnabled(event.target.checked)
|
||||
}
|
||||
/>
|
||||
<FieldLabel>Enable ingress on this server</FieldLabel>
|
||||
</Field>
|
||||
|
||||
<Field className="mt-4">
|
||||
<FieldLabel>Ingress type</FieldLabel>
|
||||
<Select
|
||||
items={ingressTypes}
|
||||
value={serverIngressType}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) {
|
||||
setServerIngressType(value);
|
||||
}
|
||||
}}
|
||||
disabled={!serverIngressEnabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Select ingress type"
|
||||
className="h-10 w-full rounded-md px-3 text-sm"
|
||||
aria-invalid={
|
||||
serverErrors.ingress_type ? true : undefined
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder="Select ingress type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<SelectGroup>
|
||||
{ingressTypes.map((ingressType) => (
|
||||
<SelectItem
|
||||
key={ingressType.value}
|
||||
value={ingressType.value}
|
||||
>
|
||||
{ingressType.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={serverErrors.ingress_type?.[0]} />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>WireGuard listen override</FieldLabel>
|
||||
@@ -1701,13 +1770,13 @@ export default function Clusters({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update builder scheduling limits and Caddy ingress for {editingServer?.name ?? 'this server'}.
|
||||
Update builder scheduling limits and ingress for {editingServer?.name ?? 'this server'}.
|
||||
Networking and bootstrap settings stay locked after creation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="mt-5 flex flex-col gap-4" onSubmit={updateServer}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<section className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<Field className="flex-row items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1717,39 +1786,76 @@ export default function Clusters({
|
||||
<FieldLabel>Enable builder on this server</FieldLabel>
|
||||
</Field>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Builder capacity</FieldLabel>
|
||||
<Input
|
||||
value={editServerBuilderCapacity}
|
||||
onChange={(event) => setEditServerBuilderCapacity(event.target.value)}
|
||||
inputMode="numeric"
|
||||
aria-invalid={editServerErrors.builder_capacity ? true : undefined}
|
||||
/>
|
||||
<FieldError message={editServerErrors.builder_capacity?.[0]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Builder CPU quota</FieldLabel>
|
||||
<Input
|
||||
value={editServerBuilderCpuQuota}
|
||||
onChange={(event) => setEditServerBuilderCpuQuota(event.target.value)}
|
||||
placeholder="200%"
|
||||
aria-invalid={editServerErrors.builder_cpu_quota ? true : undefined}
|
||||
/>
|
||||
<FieldError message={editServerErrors.builder_cpu_quota?.[0]} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<Field className="flex-row items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editServerIngressEnabled}
|
||||
onChange={(event) => setEditServerIngressEnabled(event.target.checked)}
|
||||
/>
|
||||
<FieldLabel>Enable Caddy ingress on this server</FieldLabel>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Builder capacity</FieldLabel>
|
||||
<Input
|
||||
value={editServerBuilderCapacity}
|
||||
onChange={(event) => setEditServerBuilderCapacity(event.target.value)}
|
||||
inputMode="numeric"
|
||||
aria-invalid={editServerErrors.builder_capacity ? true : undefined}
|
||||
/>
|
||||
<FieldError message={editServerErrors.builder_capacity?.[0]} />
|
||||
<FieldLabel>Enable ingress on this server</FieldLabel>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Builder CPU quota</FieldLabel>
|
||||
<Input
|
||||
value={editServerBuilderCpuQuota}
|
||||
onChange={(event) => setEditServerBuilderCpuQuota(event.target.value)}
|
||||
placeholder="200%"
|
||||
aria-invalid={editServerErrors.builder_cpu_quota ? true : undefined}
|
||||
/>
|
||||
<FieldError message={editServerErrors.builder_cpu_quota?.[0]} />
|
||||
<Field className="mt-4">
|
||||
<FieldLabel>Ingress type</FieldLabel>
|
||||
<Select
|
||||
items={ingressTypes}
|
||||
value={editServerIngressType}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) {
|
||||
setEditServerIngressType(value);
|
||||
}
|
||||
}}
|
||||
disabled={!editServerIngressEnabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Select ingress type"
|
||||
className="h-10 w-full rounded-md px-3 text-sm"
|
||||
aria-invalid={editServerErrors.ingress_type ? true : undefined}
|
||||
>
|
||||
<SelectValue placeholder="Select ingress type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="start" sideOffset={4}>
|
||||
<SelectGroup>
|
||||
{ingressTypes.map((ingressType) => (
|
||||
<SelectItem
|
||||
key={ingressType.value}
|
||||
value={ingressType.value}
|
||||
>
|
||||
{ingressType.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={editServerErrors.ingress_type?.[0]} />
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Host, bootstrap credentials, node address, and WireGuard overrides are not
|
||||
|
||||
@@ -2,6 +2,11 @@ import { Head } from '@inertiajs/react';
|
||||
import { useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent, type WheelEvent } from 'react';
|
||||
|
||||
import { AppNavbar } from '@/components/app-navbar';
|
||||
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision';
|
||||
import { csrfToken } from '@/lib/csrf';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -25,6 +30,13 @@ type V5CanvasResourceUpdatedEvent = {
|
||||
caddyIngress: V5CaddyIngress | null;
|
||||
};
|
||||
|
||||
type IngressModalState = {
|
||||
application: V5Application;
|
||||
domains: string;
|
||||
internalPort: string;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type EchoChannel = {
|
||||
listen: (event: string, callback: (payload: unknown) => void) => EchoChannel;
|
||||
subscribed?: (callback: () => void) => EchoChannel;
|
||||
@@ -148,6 +160,9 @@ export default function Dashboard({
|
||||
const [selectedNginxServerId, setSelectedNginxServerId] = useState<string>(nginxServers[0]?.id ?? '');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [ingressModal, setIngressModal] = useState<IngressModalState | null>(null);
|
||||
const [isSavingIngress, setIsSavingIngress] = useState(false);
|
||||
const [savingIngressApplicationId, setSavingIngressApplicationId] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasCanvasNodes = applications.length > 0 || ingresses.length > 0;
|
||||
|
||||
@@ -649,34 +664,69 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
}
|
||||
}
|
||||
|
||||
async function updateApplicationIngress(application: V5Application, enabled: boolean): Promise<void> {
|
||||
function openApplicationIngressModal(application: V5Application): void {
|
||||
setNotice(null);
|
||||
|
||||
const domains = enabled
|
||||
? window
|
||||
.prompt('Domains for this app, separated by commas', application.domains.join(', '))
|
||||
?.split(',')
|
||||
.map((domain) => domain.trim())
|
||||
.filter(Boolean)
|
||||
: application.domains;
|
||||
|
||||
if (enabled && (!domains || domains.length === 0)) {
|
||||
setNotice('Add at least one domain before enabling app ingress.');
|
||||
if (!application.serverIngressEnabled) {
|
||||
setNotice('Enable ingress on the server before enabling app ingress.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const internalPort = enabled
|
||||
? Number(window.prompt('Internal container port', String(application.internalPort ?? '')))
|
||||
: application.internalPort;
|
||||
setIngressModal({
|
||||
application,
|
||||
domains: application.domains.join(', '),
|
||||
internalPort: application.internalPort ? String(application.internalPort) : '',
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled && (!Number.isInteger(internalPort) || Number(internalPort) < 1 || Number(internalPort) > 65535)) {
|
||||
setNotice('Choose a valid internal port before enabling app ingress.');
|
||||
async function disableApplicationIngress(application: V5Application): Promise<void> {
|
||||
await saveApplicationIngress(application, false, application.domains, application.internalPort);
|
||||
}
|
||||
|
||||
async function submitApplicationIngress(): Promise<void> {
|
||||
if (!ingressModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domains = ingressModal.domains
|
||||
.split(',')
|
||||
.map((domain) => domain.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const internalPort = Number(ingressModal.internalPort);
|
||||
const invalidDomain = domains.find((domain) => !isValidDomain(domain));
|
||||
|
||||
if (domains.length === 0) {
|
||||
setIngressModal({ ...ingressModal, error: 'Add at least one valid domain.' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedInternalPort = enabled ? Number(internalPort) : application.internalPort;
|
||||
if (invalidDomain) {
|
||||
setIngressModal({ ...ingressModal, error: `${invalidDomain} is not a valid domain.` });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(internalPort) || internalPort < 1 || internalPort > 65535) {
|
||||
setIngressModal({ ...ingressModal, error: 'Choose a valid internal port between 1 and 65535.' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await saveApplicationIngress(ingressModal.application, true, [...new Set(domains)], internalPort);
|
||||
}
|
||||
|
||||
async function saveApplicationIngress(
|
||||
application: V5Application,
|
||||
enabled: boolean,
|
||||
domains: string[],
|
||||
internalPort: number | null,
|
||||
): Promise<void> {
|
||||
setNotice(null);
|
||||
setIsSavingIngress(true);
|
||||
setSavingIngressApplicationId(application.id);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/v5/applications/${application.id}/ingress`, {
|
||||
@@ -689,13 +739,20 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ingress_enabled: enabled,
|
||||
internal_port: selectedInternalPort,
|
||||
internal_port: internalPort,
|
||||
domains,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setNotice('Could not update application ingress.');
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
const message = payload?.message ?? 'Could not update application ingress.';
|
||||
|
||||
if (ingressModal) {
|
||||
setIngressModal({ ...ingressModal, error: message });
|
||||
} else {
|
||||
setNotice(message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -706,11 +763,63 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
candidate.id === payload.application.id ? payload.application : candidate,
|
||||
),
|
||||
);
|
||||
setIngressModal(null);
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : 'Could not update application ingress.');
|
||||
const message = error instanceof Error ? error.message : 'Could not update application ingress.';
|
||||
|
||||
if (ingressModal) {
|
||||
setIngressModal({ ...ingressModal, error: message });
|
||||
} else {
|
||||
setNotice(message);
|
||||
}
|
||||
} finally {
|
||||
setIsSavingIngress(false);
|
||||
setSavingIngressApplicationId(null);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function renderIngressButton(application: V5Application) {
|
||||
const isDisabled = !application.ingressEnabled && !application.serverIngressEnabled;
|
||||
const isApplicationIngressSaving = savingIngressApplicationId === application.id;
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
disabled={isDisabled || isApplicationIngressSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
application.ingressEnabled
|
||||
? void disableApplicationIngress(application)
|
||||
: openApplicationIngressModal(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"
|
||||
>
|
||||
{isApplicationIngressSaving ? '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>
|
||||
);
|
||||
}
|
||||
|
||||
async function addNginx(): Promise<void> {
|
||||
setIsCreating(true);
|
||||
setNotice(null);
|
||||
@@ -1091,7 +1200,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<div className="h-dvh overflow-hidden bg-background text-foreground">
|
||||
@@ -1552,17 +1661,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
? `${application.domains.length} domain${application.domains.length === 1 ? '' : 's'} → ${application.internalPort ?? 'no port'}`
|
||||
: 'Private'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void updateApplicationIngress(application, !application.ingressEnabled);
|
||||
}}
|
||||
className="rounded-sm border border-border px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wide text-foreground transition hover:bg-muted"
|
||||
>
|
||||
{application.ingressEnabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
{renderIngressButton(application)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -1572,6 +1671,76 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{ingressModal && (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isSavingIngress) {
|
||||
setIngressModal(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable app ingress</DialogTitle>
|
||||
<DialogDescription>
|
||||
Route public domains to {ingressModal.application.name} through the server ingress.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="mt-6 flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitApplicationIngress();
|
||||
}}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel>Domains</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
value={ingressModal.domains}
|
||||
onChange={(event) =>
|
||||
setIngressModal({ ...ingressModal, domains: event.target.value, error: null })
|
||||
}
|
||||
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={ingressModal.internalPort}
|
||||
onChange={(event) =>
|
||||
setIngressModal({ ...ingressModal, internalPort: event.target.value, error: null })
|
||||
}
|
||||
placeholder="3000"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{ingressModal.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">{ingressModal.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="coolify" disabled={isSavingIngress}>
|
||||
{isSavingIngress ? 'Saving...' : 'Enable ingress'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-none border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-none data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-none bg-popover fill-popover data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -9,12 +9,14 @@ export type V5Server = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
type: string;
|
||||
status: string;
|
||||
capabilities: string[];
|
||||
builderEnabled: boolean;
|
||||
builderCapacity: number;
|
||||
builderCpuQuota: string;
|
||||
ingressEnabled: boolean;
|
||||
ingressType: string | null;
|
||||
uuid: string | null;
|
||||
nodeAddress: string | null;
|
||||
wireguardListenPortOverride: number | null;
|
||||
@@ -90,6 +92,7 @@ export type V5Application = {
|
||||
statusMessage: string | null;
|
||||
runtimeContainerId: string | null;
|
||||
serverName: string | null;
|
||||
serverIngressEnabled: boolean;
|
||||
meshNamespace: string;
|
||||
ingressEnabled: boolean;
|
||||
internalPort: number | null;
|
||||
|
||||
Reference in new issue
Block a user