mirror of
https://github.com/tiennm99/coolify.git
synced 2026-09-05 12:16:52 +00:00
feat(v5): add app configuration inspector
Add a sheet-based dashboard inspector with overview, networking, and advanced tabs for applications. Switch Caddy proxy actions to the generic Flux ingress apply and stop payloads.
This commit is contained in:
@@ -27,7 +27,7 @@ class StartCaddyIngress
|
||||
}
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run($this->applications($server));
|
||||
$output = $this->fluxClient->applyCaddyIngress($hostId, $configuration['caddyfile'], $configuration['apps']);
|
||||
$output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps']));
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update([
|
||||
@@ -39,6 +39,21 @@ class StartCaddyIngress
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, caddyfile: string}> $apps
|
||||
* @return array<int, array{name: string, config: string}>
|
||||
*/
|
||||
private function ingressApps(array $apps): array
|
||||
{
|
||||
return array_map(
|
||||
fn (array $app): array => [
|
||||
'name' => $app['name'],
|
||||
'config' => $app['caddyfile'],
|
||||
],
|
||||
$apps
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Application>
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@ class StopCaddyIngress
|
||||
return 'Server is missing its Flux host id.';
|
||||
}
|
||||
|
||||
$output = $this->fluxClient->stopCaddyIngress($hostId);
|
||||
$output = $this->fluxClient->stopIngress($hostId, 'caddy');
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update(['ingress_status' => 'exited']);
|
||||
|
||||
@@ -22,27 +22,29 @@ class FluxClient
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{name: string, caddyfile: string}> $apps
|
||||
* @param array<int, array{name: string, config: string}> $apps
|
||||
*/
|
||||
public function applyCaddyIngress(string $hostId, string $caddyfile, array $apps = [], string $meshNetwork = 'coolify-default-mesh'): string
|
||||
public function applyIngress(string $hostId, string $kind, string $config, array $apps = [], string $meshNetwork = 'coolify-default-mesh'): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'apply_caddy_ingress',
|
||||
'caddyfile' => $caddyfile,
|
||||
'type' => 'ingress.apply',
|
||||
'kind' => $kind,
|
||||
'config' => $config,
|
||||
'apps' => $apps,
|
||||
'mesh_network' => $meshNetwork,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Caddy ingress applied.');
|
||||
return $this->output($payload, 'Ingress applied.');
|
||||
}
|
||||
|
||||
public function stopCaddyIngress(string $hostId): string
|
||||
public function stopIngress(string $hostId, string $kind): string
|
||||
{
|
||||
$payload = $this->dispatch($hostId, [
|
||||
'type' => 'stop_caddy_ingress',
|
||||
'type' => 'ingress.stop',
|
||||
'kind' => $kind,
|
||||
]);
|
||||
|
||||
return $this->output($payload, 'Caddy ingress stopped.');
|
||||
return $this->output($payload, 'Ingress stopped.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,9 @@ 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 { 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { resolveCanvasNodeLayout, resolveCanvasNodePosition, type CanvasNodeBounds } from '@/lib/canvas-collision';
|
||||
import { csrfToken } from '@/lib/csrf';
|
||||
@@ -152,6 +155,7 @@ export default function Dashboard({
|
||||
const [connections, setConnections] = useState<CanvasConnection[]>(initialResourceConnections);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
|
||||
const [selectedApplicationId, setSelectedApplicationId] = useState<string | null>(null);
|
||||
const [selectedInspectorApplicationId, setSelectedInspectorApplicationId] = useState<string | null>(null);
|
||||
const [connectionPortInput, setConnectionPortInput] = useState<Record<string, string>>({});
|
||||
const [draftConnection, setDraftConnection] = useState<DraftConnection | null>(null);
|
||||
const [viewport, setViewport] = useState<Viewport>({ x: 0, y: 0, zoom: 1 });
|
||||
@@ -173,6 +177,10 @@ export default function Dashboard({
|
||||
}),
|
||||
[applications],
|
||||
);
|
||||
const selectedInspectorApplication = useMemo(
|
||||
() => applications.find((application) => application.id === selectedInspectorApplicationId) ?? null,
|
||||
[applications, selectedInspectorApplicationId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const settledResources = settleCanvasResources(initialApplications, caddyIngresses);
|
||||
@@ -183,6 +191,7 @@ export default function Dashboard({
|
||||
setSelectedNginxServerId((currentServerId) => currentServerId || nginxServers[0]?.id || '');
|
||||
setSelectedConnectionId(null);
|
||||
setSelectedApplicationId(null);
|
||||
setSelectedInspectorApplicationId(null);
|
||||
centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);
|
||||
}, [initialApplications, caddyIngresses, initialResourceConnections, selectedProjectUuid, selectedEnvironmentUuid]);
|
||||
|
||||
@@ -1029,6 +1038,12 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
setSelectedApplicationId(null);
|
||||
}
|
||||
|
||||
function openApplicationInspector(event: MouseEvent<HTMLElement>, application: V5Application): void {
|
||||
event.stopPropagation();
|
||||
setSelectedApplicationId(application.id);
|
||||
setSelectedInspectorApplicationId(application.id);
|
||||
}
|
||||
|
||||
function connectionTargetFromPointer(event: PointerEvent<HTMLDivElement>): HTMLElement | null {
|
||||
const pointerTarget = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;
|
||||
|
||||
@@ -1586,6 +1601,7 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
transform: `translate3d(${application.canvasX}px, ${application.canvasY}px, 0)`,
|
||||
}}
|
||||
onPointerDown={(event) => startApplicationDrag(event, application)}
|
||||
onDoubleClick={(event) => openApplicationInspector(event, application)}
|
||||
>
|
||||
{CONNECTOR_SIDES.map((side) => (
|
||||
<button
|
||||
@@ -1626,6 +1642,14 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
>
|
||||
{application.status}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => openApplicationInspector(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()}
|
||||
@@ -1672,6 +1696,151 @@ function normalizeConnection(connection: V5ResourceConnection): CanvasConnection
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
open={selectedInspectorApplication !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedInspectorApplicationId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent side="right" className="w-full overflow-y-auto 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}>
|
||||
{selectedInspectorApplication && (
|
||||
<>
|
||||
<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{' '}
|
||||
{selectedInspectorApplication.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 overflow-x-auto" variant="line">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="networking">Networking</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={selectedInspectorApplication.name} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status</FieldLabel>
|
||||
<Input value={selectedInspectorApplication.status} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Image</FieldLabel>
|
||||
<Input value={selectedInspectorApplication.image} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Server</FieldLabel>
|
||||
<Input value={selectedInspectorApplication.serverName ?? 'Unknown'} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Container</FieldLabel>
|
||||
<Input value={selectedInspectorApplication.containerName} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Runtime container ID</FieldLabel>
|
||||
<Input value={selectedInspectorApplication.runtimeContainerId ?? 'Not available'} readOnly />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Status message</FieldLabel>
|
||||
<Textarea
|
||||
value={selectedInspectorApplication.statusMessage ?? '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={selectedInspectorApplication.meshNamespace} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Mesh FQDN</FieldLabel>
|
||||
<Input value={selectedInspectorApplication.meshFqdn} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Internal port</FieldLabel>
|
||||
<Input value={selectedInspectorApplication.internalPort?.toString() ?? 'Not configured'} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Public ingress</FieldLabel>
|
||||
<Input value={selectedInspectorApplication.ingressEnabled ? 'Enabled' : 'Private'} readOnly />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Domains</FieldLabel>
|
||||
<Textarea
|
||||
value={
|
||||
selectedInspectorApplication.domains.length > 0
|
||||
? selectedInspectorApplication.domains.join('\n')
|
||||
: 'No public domains configured.'
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{renderIngressButton(selectedInspectorApplication)}
|
||||
<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="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={selectedInspectorApplication.id} readOnly />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Canvas position</FieldLabel>
|
||||
<Input
|
||||
value={`${selectedInspectorApplication.canvasX}, ${selectedInspectorApplication.canvasY}`}
|
||||
readOnly
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Raw app config</FieldLabel>
|
||||
<Textarea
|
||||
value={JSON.stringify(selectedInspectorApplication, null, 2)}
|
||||
readOnly
|
||||
className="min-h-80 font-mono text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{ingressModal && (
|
||||
<Dialog
|
||||
open
|
||||
|
||||
@@ -21,12 +21,19 @@ function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
function SheetOverlay({
|
||||
className,
|
||||
blur = true,
|
||||
...props
|
||||
}: SheetPrimitive.Backdrop.Props & {
|
||||
blur?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/10 text-xs/relaxed transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs',
|
||||
'fixed inset-0 z-50 bg-black/10 text-xs/relaxed transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||
blur && 'supports-backdrop-filter:backdrop-blur-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -39,14 +46,16 @@ function SheetContent({
|
||||
children,
|
||||
side = 'right',
|
||||
showCloseButton = true,
|
||||
blurOverlay = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
showCloseButton?: boolean;
|
||||
blurOverlay?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetOverlay blur={blurOverlay} />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Tabs as TabsPrimitive } from '@base-ui/react/tabs';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Tabs({ className, orientation = 'horizontal', ...props }: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn('group/tabs flex gap-2 data-horizontal:flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
'group/tabs-list inline-flex w-fit items-center justify-center rounded-none p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-muted',
|
||||
line: 'gap-1 bg-transparent',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({ className, variant = 'default', ...props }: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent',
|
||||
'data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground',
|
||||
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return <TabsPrimitive.Panel data-slot="tabs-content" className={cn('flex-1 text-xs/relaxed outline-none', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -184,15 +184,16 @@ it('generates http-only caddy routes for application ingress', function () {
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyCaddyIngress')
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->with(
|
||||
'100.64.0.10',
|
||||
'caddy',
|
||||
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
|
||||
Mockery::on(fn (array $apps): bool => count($apps) === 1
|
||||
&& str_contains($apps[0]['caddyfile'], 'http://app.example.com {')
|
||||
&& ! str_contains($apps[0]['caddyfile'], 'https://')
|
||||
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
|
||||
&& str_contains($apps[0]['config'], 'http://app.example.com {')
|
||||
&& ! str_contains($apps[0]['config'], 'https://')
|
||||
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
|
||||
)
|
||||
->andReturn('Caddy ingress applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
@@ -3241,7 +3242,7 @@ it('rejects application ingress when server ingress is disabled', function () {
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldNotReceive('applyCaddyIngress');
|
||||
$fluxClient->shouldNotReceive('applyIngress');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
@@ -3305,10 +3306,11 @@ it('enables application ingress without publishing domains by default', function
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyCaddyIngress')
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->with(
|
||||
'100.64.0.10',
|
||||
'caddy',
|
||||
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
|
||||
[]
|
||||
)
|
||||
@@ -3371,7 +3373,7 @@ it('validates application ingress domains', function () {
|
||||
]);
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient->shouldNotReceive('applyCaddyIngress');
|
||||
$fluxClient->shouldNotReceive('applyIngress');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
$this
|
||||
@@ -3430,14 +3432,15 @@ it('enables application ingress with explicit domains and port', function () {
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyCaddyIngress')
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->with(
|
||||
'100.64.0.10',
|
||||
'caddy',
|
||||
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
|
||||
Mockery::on(fn (array $apps): bool => count($apps) === 1
|
||||
&& str_contains($apps[0]['caddyfile'], 'http://app.example.com {')
|
||||
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
|
||||
&& str_contains($apps[0]['config'], 'http://app.example.com {')
|
||||
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:3000'))
|
||||
)
|
||||
->andReturn('Caddy ingress applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
@@ -3502,7 +3505,7 @@ it('returns flux error details when application ingress sync fails', function ()
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyCaddyIngress')
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('start Caddy ingress: podman exited with status 125'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
@@ -3572,15 +3575,16 @@ it('syncs caddy ingress routes through flux when enabling ingress on an installe
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyCaddyIngress')
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->with(
|
||||
'100.64.0.10',
|
||||
'caddy',
|
||||
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'import apps/*.caddy')),
|
||||
Mockery::on(fn (array $apps): bool => count($apps) === 1
|
||||
&& str_contains($apps[0]['caddyfile'], 'http://nginx.example.com {')
|
||||
&& str_contains($apps[0]['caddyfile'], 'http://www.nginx.example.com {')
|
||||
&& str_contains($apps[0]['caddyfile'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080'))
|
||||
&& str_contains($apps[0]['config'], 'http://nginx.example.com {')
|
||||
&& str_contains($apps[0]['config'], 'http://www.nginx.example.com {')
|
||||
&& str_contains($apps[0]['config'], 'reverse_proxy coolify-v5-nginx-test.default.coolify.internal:8080'))
|
||||
)
|
||||
->andReturn('Caddy ingress applied.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
@@ -3651,7 +3655,7 @@ it('returns flux error details when server ingress activation fails', function (
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyCaddyIngress')
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('validate Caddyfile: unrecognized directive'));
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
@@ -4076,6 +4080,8 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function ()
|
||||
->not->toContain('function csrfToken()')
|
||||
->toContain("import { csrfToken } from '@/lib/csrf';")
|
||||
->toContain("import { Button } from '@/components/ui/button';")
|
||||
->toContain("import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';")
|
||||
->toContain("import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';")
|
||||
->not->toContain("fetch('/v5/clusters'")
|
||||
->toContain('<AppNavbar')
|
||||
->toContain('bg-background text-foreground')
|
||||
@@ -4087,6 +4093,20 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function ()
|
||||
->toContain('server_id: selectedNginxServerId || null')
|
||||
->toContain('Center')
|
||||
->toContain('Delete')
|
||||
->toContain('App configuration')
|
||||
->toContain('openApplicationInspector')
|
||||
->toContain('selectedInspectorApplication')
|
||||
->toContain('onDoubleClick={(event) => openApplicationInspector(event, application)}')
|
||||
->toContain('open={selectedInspectorApplication !== null}')
|
||||
->toContain('<SheetContent side="right" className="w-full overflow-y-auto 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]"')
|
||||
->toContain('showCloseButton blurOverlay={false}')
|
||||
->toContain('<SheetHeader className="p-6 pb-4">')
|
||||
->toContain('<div className="flex flex-1 flex-col gap-6 px-6 pb-6">')
|
||||
->toContain('<Tabs defaultValue="overview"')
|
||||
->toContain('<TabsTrigger value="overview">Overview</TabsTrigger>')
|
||||
->toContain('<TabsTrigger value="networking">Networking</TabsTrigger>')
|
||||
->toContain('<TabsTrigger value="advanced">Advanced</TabsTrigger>')
|
||||
->toContain('Double-click an application card to open configuration.')
|
||||
->toContain("method: 'DELETE'")
|
||||
->toContain('removeApplication')
|
||||
->toContain('useEffect(() => {')
|
||||
@@ -4106,6 +4126,10 @@ it('defines the v5 dashboard page as a shadcn styled canvas shell', function ()
|
||||
->not->toContain("import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';")
|
||||
->not->toContain("fetch('/v5/selection'");
|
||||
|
||||
expect(file_get_contents($sheetPath))
|
||||
->toContain('blurOverlay = true')
|
||||
->toContain('<SheetOverlay blur={blurOverlay} />');
|
||||
|
||||
expect($navbar)
|
||||
->toContain("import { Link, router, usePage } from '@inertiajs/react';")
|
||||
->toContain("import { cn } from '@/lib/utils';")
|
||||
|
||||
@@ -110,10 +110,11 @@ it('applies caddy ingress configuration through flux instead of ssh', function (
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('applyCaddyIngress')
|
||||
->shouldReceive('applyIngress')
|
||||
->once()
|
||||
->with(
|
||||
'100.64.0.10',
|
||||
'caddy',
|
||||
Mockery::on(fn (string $caddyfile): bool => str_contains($caddyfile, 'respond /coolify-health 200')),
|
||||
[]
|
||||
)
|
||||
@@ -144,9 +145,9 @@ it('stops caddy ingress through flux instead of ssh', function () {
|
||||
|
||||
$fluxClient = Mockery::mock(FluxClient::class);
|
||||
$fluxClient
|
||||
->shouldReceive('stopCaddyIngress')
|
||||
->shouldReceive('stopIngress')
|
||||
->once()
|
||||
->with('100.64.0.10')
|
||||
->with('100.64.0.10', 'caddy')
|
||||
->andReturn('Caddy ingress stopped.');
|
||||
app()->instance(FluxClient::class, $fluxClient);
|
||||
|
||||
@@ -173,7 +174,7 @@ it('includes flux error response details when dispatch returns a non success sta
|
||||
'Content-Length: '.strlen($body)."\r\n".
|
||||
"\r\n".
|
||||
$body,
|
||||
fn () => (new FluxClient)->applyCaddyIngress('100.64.0.10', 'example.com { respond "ok" }')
|
||||
fn () => (new FluxClient)->applyIngress('100.64.0.10', 'caddy', 'example.com { respond "ok" }')
|
||||
);
|
||||
})->throws(RuntimeException::class, 'start Caddy ingress: podman exited with status 125');
|
||||
|
||||
@@ -184,7 +185,7 @@ it('uses a friendly message when flux returns an invalid http response', functio
|
||||
|
||||
withFakeFluxSocket(
|
||||
'',
|
||||
fn () => (new FluxClient)->applyCaddyIngress('100.64.0.10', 'example.com { respond "ok" }')
|
||||
fn () => (new FluxClient)->applyIngress('100.64.0.10', 'caddy', 'example.com { respond "ok" }')
|
||||
);
|
||||
})->throws(RuntimeException::class, 'Flux did not return a response before the timeout.');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user