mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-30 07:35:15 +02:00
Merge remote-tracking branch 'upstream/dev' into feature-i18n
This commit is contained in:
commit
e8870cf174
38 changed files with 3440 additions and 2362 deletions
717
src/components/ContainersSelector.tsx
Normal file
717
src/components/ContainersSelector.tsx
Normal file
|
@ -0,0 +1,717 @@
|
|||
import { useEffect, useState, FC, useCallback, useMemo } from "react";
|
||||
import {
|
||||
ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
flexRender,
|
||||
getFilteredRowModel,
|
||||
VisibilityState
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Search, RefreshCw, Filter, Columns } from "lucide-react";
|
||||
import { GetSiteResponse, Container } from "@server/routers/site";
|
||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||
|
||||
// Type definitions based on the JSON structure
|
||||
|
||||
interface ContainerSelectorProps {
|
||||
site: GetSiteResponse;
|
||||
onContainerSelect?: (hostname: string, port?: number) => void;
|
||||
}
|
||||
|
||||
export const ContainersSelector: FC<ContainerSelectorProps> = ({
|
||||
site,
|
||||
onContainerSelect
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const { isAvailable, containers, fetchContainers } = useDockerSocket(
|
||||
site.siteId
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAvailable) {
|
||||
fetchContainers();
|
||||
}
|
||||
}, [isAvailable]);
|
||||
|
||||
if (!site || !isAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleContainerSelect = (container: Container, port?: number) => {
|
||||
// Extract hostname - prefer IP address from networks, fallback to container name
|
||||
const hostname = getContainerHostname(container);
|
||||
onContainerSelect?.(hostname, port);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="squareOutline"
|
||||
size="icon"
|
||||
className="absolute top-[35%] right-0"
|
||||
>
|
||||
<span className="scale-125">🐋</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[75vw] max-h-[75vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Containers in <b>{site.name}</b>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select any container (w/ port) to use as target for
|
||||
your resource
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<DockerContainersTable
|
||||
containers={containers}
|
||||
onContainerSelect={handleContainerSelect}
|
||||
onRefresh={() => fetchContainers()}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="squareOutline"
|
||||
size="icon"
|
||||
className="absolute top-[35%] right-0"
|
||||
>
|
||||
<span className="scale-125">🐋</span>
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>
|
||||
Containers in <b>{site.name}</b>
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Select any container to use as target for your resource
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4">
|
||||
<DockerContainersTable
|
||||
containers={containers}
|
||||
onContainerSelect={handleContainerSelect}
|
||||
onRefresh={fetchContainers}
|
||||
/>
|
||||
</div>
|
||||
<DrawerFooter className="pt-2">
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
const DockerContainersTable: FC<{
|
||||
containers: Container[];
|
||||
onContainerSelect: (container: Container, port?: number) => void;
|
||||
onRefresh: () => void;
|
||||
}> = ({ containers, onContainerSelect, onRefresh }) => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [hideContainersWithoutPorts, setHideContainersWithoutPorts] =
|
||||
useState(true);
|
||||
const [hideStoppedContainers, setHideStoppedContainers] = useState(false);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
labels: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setGlobalFilter(searchInput);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
const getExposedPorts = useCallback((container: Container): number[] => {
|
||||
const ports: number[] = [];
|
||||
|
||||
container.ports?.forEach((port) => {
|
||||
if (port.privatePort) {
|
||||
ports.push(port.privatePort);
|
||||
}
|
||||
});
|
||||
|
||||
return [...new Set(ports)]; // Remove duplicates
|
||||
}, []);
|
||||
|
||||
const globalFilterFunction = useCallback(
|
||||
(row: any, columnId: string, value: string) => {
|
||||
const container = row.original as Container;
|
||||
const searchValue = value.toLowerCase();
|
||||
|
||||
// Search across all relevant fields
|
||||
const searchableFields = [
|
||||
container.name,
|
||||
container.image,
|
||||
container.state,
|
||||
container.status,
|
||||
getContainerHostname(container),
|
||||
...Object.keys(container.networks),
|
||||
...Object.values(container.networks)
|
||||
.map((n) => n.ipAddress)
|
||||
.filter(Boolean),
|
||||
...getExposedPorts(container).map((p) => p.toString()),
|
||||
...Object.entries(container.labels).flat()
|
||||
];
|
||||
|
||||
return searchableFields.some((field) =>
|
||||
field?.toString().toLowerCase().includes(searchValue)
|
||||
);
|
||||
},
|
||||
[getExposedPorts]
|
||||
);
|
||||
|
||||
const columns: ColumnDef<Container>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "image",
|
||||
header: "Image",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.image}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "state",
|
||||
header: "State",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.state === "running"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{row.original.state}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "networks",
|
||||
header: "Networks",
|
||||
cell: ({ row }) => {
|
||||
const networks = Object.keys(row.original.networks);
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{networks.length > 0
|
||||
? networks.map((n) => (
|
||||
<Badge key={n} variant="outlinePrimary">
|
||||
{n}
|
||||
</Badge>
|
||||
))
|
||||
: "-"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "hostname",
|
||||
header: "Hostname/IP",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm font-mono">
|
||||
{getContainerHostname(row.original)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "labels",
|
||||
header: "Labels",
|
||||
cell: ({ row }) => {
|
||||
const labels = row.original.labels || {};
|
||||
const labelEntries = Object.entries(labels);
|
||||
|
||||
if (labelEntries.length === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs hover:bg-muted"
|
||||
>
|
||||
{labelEntries.length} label
|
||||
{labelEntries.length !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start">
|
||||
<ScrollArea className="w-64 h-64">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">
|
||||
Container Labels
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{labelEntries.map(([key, value]) => (
|
||||
<div key={key} className="text-xs">
|
||||
<div className="font-mono font-medium text-foreground">
|
||||
{key}
|
||||
</div>
|
||||
<div className="font-mono text-muted-foreground pl-2 break-all">
|
||||
{value || "<empty>"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "ports",
|
||||
header: "Ports",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const ports = getExposedPorts(row.original);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{ports.slice(0, 2).map((port) => (
|
||||
<Button
|
||||
key={port}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() =>
|
||||
onContainerSelect(row.original, port)
|
||||
}
|
||||
>
|
||||
{port}
|
||||
</Button>
|
||||
))}
|
||||
{ports.length > 2 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="link" size="sm">
|
||||
+{ports.length - 2} more
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="w-auto"
|
||||
align="end"
|
||||
>
|
||||
{ports.slice(2).map((port) => (
|
||||
<Button
|
||||
key={port}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() =>
|
||||
onContainerSelect(
|
||||
row.original,
|
||||
port
|
||||
)
|
||||
}
|
||||
>
|
||||
{port}
|
||||
</Button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onContainerSelect(row.original)}
|
||||
disabled={row.original.state !== "running"}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const initialFilters = useMemo(() => {
|
||||
let filtered = containers;
|
||||
|
||||
// Filter by port visibility
|
||||
if (hideContainersWithoutPorts) {
|
||||
filtered = filtered.filter((container) => {
|
||||
const ports = getExposedPorts(container);
|
||||
return ports.length > 0; // Show only containers WITH ports
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by container state
|
||||
if (hideStoppedContainers) {
|
||||
filtered = filtered.filter((container) => {
|
||||
return container.state === "running";
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [
|
||||
containers,
|
||||
hideContainersWithoutPorts,
|
||||
hideStoppedContainers,
|
||||
getExposedPorts
|
||||
]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: initialFilters,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
globalFilterFn: globalFilterFunction,
|
||||
state: {
|
||||
globalFilter,
|
||||
columnVisibility
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onColumnVisibilityChange: setColumnVisibility
|
||||
});
|
||||
|
||||
if (initialFilters.length === 0) {
|
||||
return (
|
||||
<div className="border rounded-md max-h-[500px] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 flex items-center justify-center py-8">
|
||||
<div className="text-center text-muted-foreground space-y-3">
|
||||
{(hideContainersWithoutPorts ||
|
||||
hideStoppedContainers) &&
|
||||
containers.length > 0 ? (
|
||||
<>
|
||||
<p>
|
||||
No containers found matching the current
|
||||
filters.
|
||||
</p>
|
||||
<div className="space-x-2">
|
||||
{hideContainersWithoutPorts && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setHideContainersWithoutPorts(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
Show containers without ports
|
||||
</Button>
|
||||
)}
|
||||
{hideStoppedContainers && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setHideStoppedContainers(false)
|
||||
}
|
||||
>
|
||||
Show stopped containers
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
No containers found. Make sure Docker containers
|
||||
are running.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md max-h-[500px] overflow-hidden flex flex-col">
|
||||
<div className="p-3 border-b bg-background space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={`Search across ${initialFilters.length} containers...`}
|
||||
value={searchInput}
|
||||
onChange={(event) =>
|
||||
setSearchInput(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
{searchInput &&
|
||||
table.getFilteredRowModel().rows.length > 0 && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
{table.getFilteredRowModel().rows.length}{" "}
|
||||
result
|
||||
{table.getFilteredRowModel().rows.length !==
|
||||
1
|
||||
? "s"
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filters
|
||||
{(hideContainersWithoutPorts ||
|
||||
hideStoppedContainers) && (
|
||||
<span className="bg-primary text-primary-foreground rounded-full w-5 h-5 text-xs flex items-center justify-center">
|
||||
{Number(
|
||||
hideContainersWithoutPorts
|
||||
) + Number(hideStoppedContainers)}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel>
|
||||
Filter Options
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hideContainersWithoutPorts}
|
||||
onCheckedChange={
|
||||
setHideContainersWithoutPorts
|
||||
}
|
||||
>
|
||||
Ports
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hideStoppedContainers}
|
||||
onCheckedChange={setHideStoppedContainers}
|
||||
>
|
||||
Stopped
|
||||
</DropdownMenuCheckboxItem>
|
||||
{(hideContainersWithoutPorts ||
|
||||
hideStoppedContainers) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setHideContainersWithoutPorts(
|
||||
false
|
||||
);
|
||||
setHideStoppedContainers(
|
||||
false
|
||||
);
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
Clear all filters
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Columns className="h-4 w-4" />
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuLabel>
|
||||
Toggle Columns
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(
|
||||
!!value
|
||||
)
|
||||
}
|
||||
>
|
||||
{column.id === "hostname"
|
||||
? "Hostname/IP"
|
||||
: column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
title="Refresh containers list"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto relative flex-1">
|
||||
<Table sticky>
|
||||
<TableHeader sticky className="bg-background border-b">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="bg-background"
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={
|
||||
row.original.state !== "running"
|
||||
? "opacity-50"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{searchInput && !globalFilter ? (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Searching...
|
||||
</div>
|
||||
) : (
|
||||
`No containers found matching "${globalFilter}".`
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getContainerHostname(container: Container): string {
|
||||
// First, try to get IP from networks
|
||||
const networks = Object.values(container.networks);
|
||||
for (const network of networks) {
|
||||
if (network.ipAddress) {
|
||||
return network.ipAddress;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to container name (works in Docker networks)
|
||||
return container.name;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
@ -23,6 +23,7 @@ import Link from "next/link";
|
|||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface LayoutProps {
|
||||
|
@ -61,6 +62,31 @@ export function Layout({
|
|||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const { theme } = useTheme();
|
||||
const [path, setPath] = useState<string>(""); // Default logo path
|
||||
|
||||
useEffect(() => {
|
||||
function getPath() {
|
||||
let lightOrDark = theme;
|
||||
|
||||
if (theme === "system" || !theme) {
|
||||
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
if (lightOrDark === "light") {
|
||||
return "/logo/word_mark_black.png";
|
||||
}
|
||||
|
||||
return "/logo/word_mark_white.png";
|
||||
}
|
||||
|
||||
setPath(getPath());
|
||||
}, [theme, env]);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
|
@ -140,12 +166,14 @@ export function Layout({
|
|||
href="/"
|
||||
className="flex items-center hidden md:block"
|
||||
>
|
||||
<Image
|
||||
src="/logo/pangolin_orange.svg"
|
||||
alt="Pangolin Logo"
|
||||
width={35}
|
||||
height={35}
|
||||
/>
|
||||
{path && (
|
||||
<Image
|
||||
src={path}
|
||||
alt="Pangolin Logo"
|
||||
width={110}
|
||||
height={25}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
{showBreadcrumbs && (
|
||||
<div className="hidden md:block overflow-x-auto scrollbar-hide">
|
||||
|
|
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import { cn } from "@app/lib/cn"
|
||||
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
|
@ -1,121 +1,138 @@
|
|||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@app/lib/cn"
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
export function TableContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="border rounded-lg bg-card">{children}</div>
|
||||
return <div className="border rounded-lg bg-card">{children}</div>;
|
||||
}
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement> & { sticky?: boolean }
|
||||
>(({ className, sticky, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("relative w-full", {
|
||||
"overflow-auto": !sticky
|
||||
})}
|
||||
>
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement> & { sticky?: boolean }
|
||||
>(({ className, sticky, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_tr]:border-b",
|
||||
{
|
||||
"sticky top-0": sticky
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-3 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-3 align-middle [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue