diff --git a/apps/web/src/components/vps-filters.tsx b/apps/web/src/components/vps-filters.tsx new file mode 100644 index 0000000..198789d --- /dev/null +++ b/apps/web/src/components/vps-filters.tsx @@ -0,0 +1,458 @@ +import { useMemo } from 'react' +import { SearchIcon, FilterIcon, XIcon, SaveIcon, TrashIcon } from 'lucide-react' + +import { Input } from '@cfdm/ui/components/input' +import { Button } from '@cfdm/ui/components/button' +import { Badge } from '@cfdm/ui/components/badge' +import { Card, CardContent } from '@cfdm/ui/components/card' +import { Checkbox } from '@cfdm/ui/components/checkbox' +import { Label } from '@cfdm/ui/components/label' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@cfdm/ui/components/dropdown-menu' + +import { SelectField } from '@/components/select-field' +import { vpsStatusLabel, tariffTypeLabel, environmentLabel } from '@/lib/format' +import type { Provider, ProviderAccount, Vps } from '@/types/entities' + +export interface VpsFiltersState { + search: string + providerId: string + providerAccountId: string + country: string + city: string + datacenter: string + status: string // 'all' | VpsStatus + environment: string // 'all' | env + tariffType: string // 'all' | TariffType + monitoring: string // 'all' | 'on' | 'off' + backup: string // 'all' | 'on' | 'off' + minVcpu: string + minRamGb: string + minDiskGb: string + project: string // '' | '__none__' | name + groupByProject: boolean + tableCompact: boolean +} + +export function buildDefaultVpsFilters(): VpsFiltersState { + return { + search: '', + providerId: '', + providerAccountId: '', + country: '', + city: '', + datacenter: '', + status: 'all', + environment: 'all', + tariffType: 'all', + monitoring: 'all', + backup: 'all', + minVcpu: '', + minRamGb: '', + minDiskGb: '', + project: '', + groupByProject: false, + tableCompact: false, + } +} + +const ALL = 'all' + +const STATUS_OPTIONS = [ + { value: ALL, label: 'Все статусы' }, + { value: 'active', label: vpsStatusLabel('active') }, + { value: 'paused', label: vpsStatusLabel('paused') }, + { value: 'archived', label: vpsStatusLabel('archived') }, +] + +const ENV_OPTIONS = [ + { value: ALL, label: 'Все окружения' }, + { value: 'prod', label: environmentLabel('prod') }, + { value: 'dev', label: environmentLabel('dev') }, + { value: 'staging', label: environmentLabel('staging') }, +] + +const TARIFF_OPTIONS = [ + { value: ALL, label: 'Все тарифы' }, + { value: 'monthly', label: tariffTypeLabel('monthly') }, + { value: 'daily', label: tariffTypeLabel('daily') }, +] + +const ON_OFF_OPTIONS = [ + { value: ALL, label: 'Любое' }, + { value: 'on', label: 'Включено' }, + { value: 'off', label: 'Выключено' }, +] + +const PRESETS_KEY = 'vps-tracker:vps-filter-presets' + +export interface VpsFilterPreset { + name: string + filters: VpsFiltersState +} + +export function loadFilterPresets(): VpsFilterPreset[] { + try { + const raw = localStorage.getItem(PRESETS_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function saveFilterPresets(presets: VpsFilterPreset[]): void { + try { + localStorage.setItem(PRESETS_KEY, JSON.stringify(presets)) + } catch { + /* ignore */ + } +} + +export function applyVpsFilters(items: Vps[], filters: VpsFiltersState): Vps[] { + const search = filters.search.toLowerCase() + const minVcpu = Number(filters.minVcpu || 0) + const minRamGb = Number(filters.minRamGb || 0) + const minDiskGb = Number(filters.minDiskGb || 0) + return items.filter((item) => { + const extraIps = Array.isArray(item.additionalIps) ? item.additionalIps.join(' ') : '' + if ( + search && + !item.ip?.toLowerCase().includes(search) && + !item.dns?.toLowerCase().includes(search) && + !item.ipv6?.toLowerCase().includes(search) && + !extraIps.toLowerCase().includes(search) && + !item.project?.toLowerCase().includes(search) && + !item.purpose?.toLowerCase().includes(search) && + !item.os?.toLowerCase().includes(search) + ) + return false + if (filters.providerId && item.providerId !== filters.providerId) return false + if (filters.providerAccountId && item.providerAccountId !== filters.providerAccountId) return false + if (filters.country && !item.country?.toLowerCase().includes(filters.country.toLowerCase())) return false + if (filters.city && !item.city?.toLowerCase().includes(filters.city.toLowerCase())) return false + if (filters.datacenter && !item.datacenter?.toLowerCase().includes(filters.datacenter.toLowerCase())) return false + if (filters.status !== ALL && item.status !== filters.status) return false + if (filters.environment !== ALL && item.environment !== filters.environment) return false + if (filters.tariffType !== ALL && item.tariffType !== filters.tariffType) return false + if (filters.monitoring !== ALL) { + const on = filters.monitoring === 'on' + if (on !== Boolean(item.monitoringEnabled)) return false + } + if (filters.backup !== ALL) { + const on = filters.backup === 'on' + if (on !== Boolean(item.backupEnabled)) return false + } + if (minVcpu && Number(item.vcpu || 0) < minVcpu) return false + if (minRamGb && Number(item.ramGb || 0) < minRamGb) return false + if (minDiskGb && Number(item.diskGb || 0) < minDiskGb) return false + const proj = (item.project || '').trim() + if (filters.project) { + if (filters.project === '__none__' ? proj : proj !== filters.project) return false + } + return true + }) +} + +export function countActiveFilters(filters: VpsFiltersState): number { + let n = 0 + if (filters.search) n++ + if (filters.providerId) n++ + if (filters.providerAccountId) n++ + if (filters.country) n++ + if (filters.city) n++ + if (filters.datacenter) n++ + if (filters.status !== ALL) n++ + if (filters.environment !== ALL) n++ + if (filters.tariffType !== ALL) n++ + if (filters.monitoring !== ALL) n++ + if (filters.backup !== ALL) n++ + if (filters.minVcpu) n++ + if (filters.minRamGb) n++ + if (filters.minDiskGb) n++ + if (filters.project) n++ + return n +} + +interface VpsFiltersProps { + filters: VpsFiltersState + onChange: (next: VpsFiltersState) => void + providers: Provider[] + providerAccounts: ProviderAccount[] + projectNameOptions: string[] + presets: VpsFilterPreset[] + onPresetsChange: (presets: VpsFilterPreset[]) => void +} + +export function VpsFilters({ + filters, + onChange, + providers, + providerAccounts, + projectNameOptions, + presets, + onPresetsChange, +}: VpsFiltersProps) { + const update = (key: K, value: VpsFiltersState[K]) => + onChange({ ...filters, [key]: value }) + + const accountOptions = useMemo( + () => + providerAccounts.filter( + (a) => !filters.providerId || a.providerId === filters.providerId, + ), + [providerAccounts, filters.providerId], + ) + + const activeCount = countActiveFilters(filters) + const hasFilters = activeCount > 0 || filters.groupByProject || filters.tableCompact + + const savePreset = () => { + const name = window.prompt('Имя пресета фильтров', `Пресет ${presets.length + 1}`) + if (!name) return + const next = [...presets.filter((p) => p.name !== name), { name, filters }] + onPresetsChange(next) + saveFilterPresets(next) + } + + const applyPreset = (preset: VpsFilterPreset) => { + onChange({ ...buildDefaultVpsFilters(), ...preset.filters }) + } + + const deletePreset = (name: string) => { + const next = presets.filter((p) => p.name !== name) + onPresetsChange(next) + saveFilterPresets(next) + } + + const reset = () => onChange(buildDefaultVpsFilters()) + + return ( + + +
+
+ + update('search', e.target.value)} + className="pl-8" + /> +
+ update('status', v ?? ALL)} + options={STATUS_OPTIONS} + triggerClassName="w-44" + /> + + onChange({ ...filters, providerId: v ?? '', providerAccountId: '' }) + } + options={providers.map((p) => ({ value: p.id, label: p.name }))} + triggerClassName="w-44" + /> + update('providerAccountId', v ?? '')} + options={accountOptions.map((a) => ({ value: a.id, label: a.name }))} + triggerClassName="w-44" + /> + update('project', v ?? '')} + options={[ + { value: '__none__', label: 'Без проекта' }, + ...projectNameOptions.map((p) => ({ value: p, label: p })), + ]} + triggerClassName="w-44" + /> + + + + Доп. фильтры + {activeCount > 0 ? {activeCount} : null} + + } + /> + + + {hasFilters ? ( + + ) : null} + + {presets.length > 0 ? ( + + Пресеты ({presets.length})} + /> + + {presets.map((p) => ( + applyPreset(p)} + className="justify-between" + > + {p.name} + { + e.stopPropagation() + deletePreset(p.name) + }} + /> + + ))} + + + ) : null} +
+ +
+
+ + update('country', e.target.value)} + className="w-36" + /> +
+
+ + update('city', e.target.value)} + className="w-36" + /> +
+
+ + update('datacenter', e.target.value)} + className="w-40" + /> +
+
+ + update('environment', v ?? ALL)} + options={ENV_OPTIONS} + triggerClassName="w-44" + /> +
+
+ + update('tariffType', v ?? ALL)} + options={TARIFF_OPTIONS} + triggerClassName="w-36" + /> +
+
+ + update('monitoring', v ?? ALL)} + options={ON_OFF_OPTIONS} + triggerClassName="w-36" + /> +
+
+ + update('backup', v ?? ALL)} + options={ON_OFF_OPTIONS} + triggerClassName="w-36" + /> +
+
+ + update('minVcpu', e.target.value)} + className="w-20" + /> +
+
+ + update('minRamGb', e.target.value)} + className="w-20" + /> +
+
+ + update('minDiskGb', e.target.value)} + className="w-20" + /> +
+ + +
+
+
+ ) +} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index bf9349a..14d5b9f 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -72,6 +72,14 @@ export function tariffTypeLabel(type: string): string { return TARIFF_TYPE_LABELS[type] ?? type } +const ENVIRONMENT_LABELS: Record = { + prod: 'Production', dev: 'Development', staging: 'Staging', +} + +export function environmentLabel(env: string): string { + return ENVIRONMENT_LABELS[env] ?? env +} + const CURRENCY_SYMBOL_MAP: Record = { '€': 'EUR', '$': 'USD', '₽': 'RUB', '£': 'GBP', '¥': 'JPY', '₴': 'UAH', '₸': 'KZT', } diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 287f655..c9c874b 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useState, useMemo } from 'react' import { PlusIcon, PencilIcon, Trash2Icon } from 'lucide-react' import { toast } from 'sonner' @@ -20,6 +20,14 @@ import { FormField } from '@/components/form-field' import { Input } from '@cfdm/ui/components/input' import { SelectField } from '@/components/select-field' import { Textarea } from '@cfdm/ui/components/textarea' +import { + VpsFilters, + applyVpsFilters, + buildDefaultVpsFilters, + loadFilterPresets, + type VpsFiltersState, + type VpsFilterPreset, +} from '@/components/vps-filters' import type { Vps } from '@/types/entities' import { vpsStatusLabel, tariffTypeLabel, formatInBaseCurrency } from '@/lib/format' @@ -43,6 +51,8 @@ function VpsPage() { const [sheetOpen, setSheetOpen] = useState(false) const [editingId, setEditingId] = useState(null) const [defaultValues, setDefaultValues] = useState(EMPTY_FORM) + const [filters, setFilters] = useState(buildDefaultVpsFilters()) + const [presets, setPresets] = useState(() => loadFilterPresets()) const createMutation = useMutation({ mutationFn: (record: VpsFormValues) => api.create('vps', record as unknown as Vps), @@ -103,6 +113,47 @@ function VpsPage() { const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map() + const projectNameOptions = useMemo(() => { + const names = new Set() + for (const p of snapshot?.serverProjects ?? []) { + const name = (p as { name?: string }).name?.trim() + if (name) names.add(name) + } + for (const v of snapshot?.vps ?? []) { + const p = (v.project || '').trim() + if (p) names.add(p) + } + return [...names].sort((a, b) => a.localeCompare(b, 'ru')) + }, [snapshot]) + + const filteredVps = useMemo( + () => applyVpsFilters(snapshot?.vps ?? [], filters), + [snapshot?.vps, filters], + ) + + const tableSections = useMemo(() => { + if (!filters.groupByProject) { + return [{ key: '_flat', label: null as string | null, items: filteredVps }] + } + const map = new Map() + for (const item of filteredVps) { + const key = (item.project || '').trim() || '__none__' + const arr = map.get(key) ?? [] + arr.push(item) + map.set(key, arr) + } + const keys = [...map.keys()].sort((a, b) => { + if (a === '__none__') return 1 + if (b === '__none__') return -1 + return a.localeCompare(b, 'ru') + }) + return keys.map((key) => ({ + key, + label: key === '__none__' ? 'Без проекта' : key, + items: map.get(key) ?? [], + })) + }, [filteredVps, filters.groupByProject]) + const columns: DataTableColumn[] = [ { key: 'ip', @@ -213,12 +264,27 @@ function VpsPage() { } > {(snap) => ( - v.id} - emptyTitle="VPS не найдены" - /> +
+ + {tableSections.map((section) => ( + v.id} + emptyTitle="VPS не найдены" + /> + ))} +
)} diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx new file mode 100644 index 0000000..404f2e6 --- /dev/null +++ b/packages/ui/src/components/checkbox.tsx @@ -0,0 +1,27 @@ +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" + +import { cn } from "@cfdm/ui/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { Checkbox }