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:
Andras Bacsai committed 2026-06-20 23:10:48 +02:00
1 parent ad0bcc8a4a
commit 1c091e9e36
22 files changed
+1120 -287

No files matched your search

+177 -71
View File
@@ -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
+202 -33
View File
@@ -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>
);
}
+64
View File
@@ -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 }
+3
View File
@@ -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;