feat(instances): add column filters and VM config formatter
Add ColumnFilter component (Popover-based faceted filter) and a formatVmConfig helper rendering "N vCPU / X GB RAM / Y GB". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
137
frontend/src/components/shared/column-filter.tsx
Normal file
137
frontend/src/components/shared/column-filter.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Column, FilterFn, RowData } from '@tanstack/react-table';
|
||||
import { ListFilter, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Filter metadata declared per-column via columnDef.meta
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
filter?: 'text' | 'select';
|
||||
formatOption?: (value: string) => string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select filter: keeps the row when its (single string) cell value is one
|
||||
* of the selected values. Built-in arrIncludesSome expects array cells, so we
|
||||
* provide our own. Empty selection passes everything.
|
||||
*/
|
||||
export const multiSelectFilter: FilterFn<unknown> = (row, columnId, value: string[]) => {
|
||||
if (!value?.length) return true;
|
||||
return value.includes(String(row.getValue(columnId)));
|
||||
};
|
||||
|
||||
interface ColumnFilterProps<TData> {
|
||||
column: Column<TData, unknown>;
|
||||
/** "text" → contains input; "select" → checklist of unique values */
|
||||
variant?: 'text' | 'select';
|
||||
/** Optional label→value formatter for select options */
|
||||
formatOption?: (value: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Funnel-style per-column filter popover for TanStack tables.
|
||||
* Text columns filter by substring (filterFn "includesString").
|
||||
* Select columns filter by a set of values (filterFn "arrIncludesSome").
|
||||
*/
|
||||
export function ColumnFilter<TData>({ column, variant = 'text', formatOption }: ColumnFilterProps<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const filterValue = column.getFilterValue();
|
||||
const isActive =
|
||||
variant === 'select'
|
||||
? Array.isArray(filterValue) && filterValue.length > 0
|
||||
: !!filterValue;
|
||||
|
||||
const uniqueValues = useMemo(() => {
|
||||
if (variant !== 'select') return [];
|
||||
return Array.from(column.getFacetedUniqueValues().keys())
|
||||
.filter((v): v is string => v != null && String(v).length > 0)
|
||||
.map(String)
|
||||
.sort();
|
||||
}, [variant, column]);
|
||||
|
||||
const selected = (Array.isArray(filterValue) ? filterValue : []) as string[];
|
||||
|
||||
const toggle = (value: string) => {
|
||||
const next = selected.includes(value)
|
||||
? selected.filter((v) => v !== value)
|
||||
: [...selected, value];
|
||||
column.setFilterValue(next.length ? next : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', isActive && 'text-amber-500')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-2">
|
||||
{variant === 'text' ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('common.filter', 'Filter…')}
|
||||
value={(filterValue as string) ?? ''}
|
||||
onChange={(e) => column.setFilterValue(e.target.value || undefined)}
|
||||
className="h-8"
|
||||
/>
|
||||
{isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground"
|
||||
onClick={() => column.setFilterValue(undefined)}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> {t('common.reset', 'Reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="max-h-60 space-y-0.5 overflow-y-auto">
|
||||
{uniqueValues.length === 0 && (
|
||||
<span className="px-1 text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
{uniqueValues.map((value) => (
|
||||
<label
|
||||
key={value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-accent"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border"
|
||||
checked={selected.includes(value)}
|
||||
onChange={() => toggle(value)}
|
||||
/>
|
||||
<span className="truncate">{formatOption ? formatOption(value) : value}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground"
|
||||
onClick={() => column.setFilterValue(undefined)}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> {t('common.reset', 'Reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/ui/popover.tsx
Normal file
47
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--transform-origin) rounded-lg bg-popover p-3 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-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-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}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -27,3 +27,21 @@ export function formatCPU(cpuFraction: number, maxcpu: number): string {
|
||||
export function vmTypeLabel(type: string): string {
|
||||
return type === 'qemu' ? 'VM' : type === 'lxc' ? 'LXC' : type.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a VM/LXC hardware configuration as "N vCPU / X GB RAM / Y GB".
|
||||
* `memory` is expected in MB; disk in GB (falls back to `maxdisk` in bytes).
|
||||
*/
|
||||
export function formatVmConfig(vm: {
|
||||
cores?: number;
|
||||
memory?: number;
|
||||
disk_size?: number;
|
||||
maxdisk?: number;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (vm.cores) parts.push(`${vm.cores} vCPU`);
|
||||
if (vm.memory) parts.push(`${Math.round(vm.memory / 1024)} GB RAM`);
|
||||
const diskGb = vm.disk_size ?? (vm.maxdisk ? Math.round(vm.maxdisk / 1024 ** 3) : 0);
|
||||
if (diskGb) parts.push(`${diskGb} GB`);
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user