feat(instances): command palette, unified nav, расширенное меню инстанса и правки табов
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s

- Командная палитра (Ctrl+K) с общим реестром nav-items и его store
- Новые общие UI-примитивы: alert, breadcrumb, checkbox, progress, switch, textarea
- Меню инстанса: добавлены «Параметры», «Мигрировать», «Изменить ресурсы»,
  «Создать резервную копию» (новый BackupDialog) и «Удалить»; исправлено
  закрытие меню при живом обновлении списка (стабильные columns + getRowId)
- Вкладки инстанса: «Вычислительные ресурсы», «Опции CPU», «Управление
  дисками» и «Изменение размера диска» перенесены с «Настройки» на
  «Оборудование» для VM (для LXC, у которых нет вкладки «Оборудование»,
  ресурсы и ресайз диска остаются в «Настройках»); общие карточки вынесены
  в ResourceCards.tsx
- Локализация: новые ключи ru/en для добавленных пунктов меню и бэкапа

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
markmorado
2026-07-17 00:58:45 +05:00
parent 8ca33681ca
commit b8536ac2e8
48 changed files with 1418 additions and 732 deletions

View File

@@ -0,0 +1,155 @@
import { useEffect, useMemo, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { useNavigate } from 'react-router';
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
import { Search } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { DialogPortal, DialogOverlay } from '@/components/ui/dialog';
import { useAuthStore } from '@/stores/auth-store';
import { useCommandPaletteStore } from '@/stores/command-palette-store';
import { navGroups, canSeeNavItem } from '@/lib/nav-items';
import { cn } from '@/lib/utils';
/**
* Global Ctrl/Cmd+K palette for jumping between sections. Mounted once in
* AppLayout; reuses the same navGroups/permission gating as the sidebar so
* the two never drift out of sync.
*/
export default function CommandPalette() {
const { open, setOpen, toggle } = useCommandPaletteStore();
const navigate = useNavigate();
const { t } = useTranslation();
const { user } = useAuthStore();
const isAdmin = !!user?.is_admin;
const permissions = user?.permissions;
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
function onKeyDown(e: globalThis.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
toggle();
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [toggle]);
const flatItems = useMemo(() => {
const q = query.trim().toLowerCase();
return navGroups.flatMap((group) =>
group.items
.filter((item) => canSeeNavItem(item, isAdmin, permissions))
.filter((item) => {
if (!q) return true;
const label = t(`nav.${item.label.toLowerCase()}`, item.label);
return label.toLowerCase().includes(q) || item.path.toLowerCase().includes(q);
})
.map((item) => ({ ...item, group: group.title }))
);
}, [query, isAdmin, permissions, t]);
// Reset transient UI state whenever the dialog's open state changes, driven
// straight from the event (not an effect) to avoid a cascading re-render.
const handleOpenChange = (next: boolean) => {
setOpen(next);
setQuery('');
setActiveIndex(0);
};
const handleQueryChange = (value: string) => {
setQuery(value);
setActiveIndex(0);
};
const go = (path: string) => {
setOpen(false);
navigate(path);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flatItems.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const item = flatItems[activeIndex];
if (item) go(item.path);
}
};
let groupCursor = '';
return (
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="command-palette"
className="fixed top-24 left-1/2 z-50 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-2xl ring-1 ring-foreground/10 outline-none 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"
>
<DialogPrimitive.Title className="sr-only">
{t('nav.search', 'Search sections')}
</DialogPrimitive.Title>
<div className="flex items-center gap-2 border-b border-border px-3.5">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('nav.searchPlaceholder', 'Jump to a section...')}
className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<kbd className="hidden shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-2xs text-muted-foreground sm:block">
Esc
</kbd>
</div>
<div className="max-h-80 overflow-y-auto p-1.5">
{flatItems.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('nav.searchEmpty', 'No results found')}
</p>
) : (
flatItems.map((item, i) => {
const showGroupHeader = item.group !== groupCursor;
groupCursor = item.group;
const Icon = item.icon;
return (
<div key={item.path}>
{showGroupHeader && (
<p className="mt-1.5 mb-1 px-2 text-2xs font-semibold uppercase tracking-wider text-muted-foreground first:mt-0">
{t(`nav.${item.group.toLowerCase()}`, item.group)}
</p>
)}
<button
type="button"
onMouseEnter={() => setActiveIndex(i)}
onClick={() => go(item.path)}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-[13px] font-medium transition-colors',
i === activeIndex
? 'bg-primary/10 text-primary'
: 'text-foreground hover:bg-accent'
)}
>
<Icon className="h-4 w-4 shrink-0" />
{t(`nav.${item.label.toLowerCase()}`, item.label)}
</button>
</div>
);
})
)}
</div>
</DialogPrimitive.Popup>
</DialogPortal>
</DialogPrimitive.Root>
);
}

View File

@@ -3,6 +3,7 @@ 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 { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
@@ -110,9 +111,7 @@ export function ColumnFilter<TData>({ column, variant = 'text', formatOption }:
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"
<Checkbox
checked={selected.includes(value)}
onChange={() => toggle(value)}
/>

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-x-2.5 gap-y-1 rounded-lg border px-3.5 py-3 text-sm has-[>svg]:grid-cols-[theme(spacing.4)_1fr] [&>svg]:mt-0.5 [&>svg]:size-4",
{
variants: {
variant: {
default: "border-border bg-card text-card-foreground [&>svg]:text-foreground",
destructive:
"border-destructive/30 bg-destructive/10 text-destructive [&>svg]:text-destructive",
warning:
"border-warning/30 bg-warning/10 text-warning [&>svg]:text-warning",
success:
"border-success/30 bg-success/10 text-success [&>svg]:text-success",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn("col-start-2 min-h-4 font-medium tracking-tight", className)}
{...props}
/>
)
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, alertVariants }

View File

@@ -0,0 +1,99 @@
import * as React from "react"
import { Link } from "react-router"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground break-words sm:gap-2",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
to,
...props
}: React.ComponentProps<typeof Link>) {
return (
<Link
data-slot="breadcrumb-link"
to={to}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-medium text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({ className, children, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5 text-muted-foreground/60", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-6 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-3.5" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
/**
* Themed native checkbox — deliberately kept as a plain `<input type="checkbox">`
* (not the base-ui Checkbox primitive) so it stays a drop-in replacement for
* every existing `onChange={(e) => ...}` / `table.getToggleSelectedHandler()`
* call site without touching handler signatures.
*/
function Checkbox({ className, ...props }: React.ComponentProps<"input">) {
return (
<input
type="checkbox"
data-slot="checkbox"
className={cn(
"size-4 shrink-0 cursor-pointer rounded-[4px] border border-input bg-transparent accent-primary outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30",
className
)}
{...props}
/>
)
}
export { Checkbox }

View File

@@ -0,0 +1,35 @@
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
/**
* Linear progress bar. Consolidates the ad-hoc `<div className="h-1.5
* rounded-full bg-track">` fills scattered across Dashboard/InstancesPage
* into one accessible (role="progressbar") primitive.
*/
function Progress({
className,
value,
...props
}: ProgressPrimitive.Root.Props & { className?: string }) {
return (
<ProgressPrimitive.Root
data-slot="progress"
value={value}
className={cn("w-full", className)}
{...props}
>
<ProgressPrimitive.Track
data-slot="progress-track"
className="block h-1.5 w-full overflow-hidden rounded-full bg-track"
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="block h-full rounded-full bg-primary transition-all duration-500 data-[state=indeterminate]:animate-pulse"
/>
</ProgressPrimitive.Track>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,23 @@
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer inline-flex h-4.5 w-8 shrink-0 items-center rounded-full border border-transparent bg-input transition-colors outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[checked]:bg-primary dark:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block size-3.5 translate-x-0.5 rounded-full bg-background shadow-xs transition-transform data-[checked]:translate-x-[15px]"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { Loader2, HardDriveDownload, CheckCircle2, XCircle } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
@@ -127,7 +128,7 @@ export default function GoldenTemplateDialog({ open, onOpenChange }: Props) {
<Input value={rootfsStorage} onChange={(e) => setRootfsStorage(e.target.value)} placeholder="local-lvm" />
</div>
<label className="flex items-end gap-2 pb-2 text-sm">
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} className="h-4 w-4" />
<Checkbox checked={force} onChange={(e) => setForce(e.target.checked)} />
{t('appstore.golden.force', 'Пересобрать')}
</label>
</div>

View File

@@ -7,6 +7,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
@@ -211,8 +212,7 @@ export default function InstallWizard({ app, open, onOpenChange }: Props) {
</Label>
{type === 'boolean' ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<Checkbox
checked={val === 'true'}
onChange={(e) => set(e.target.checked ? 'true' : 'false')}
/>

View File

@@ -4,6 +4,7 @@ import { Archive, Play, Calendar, RotateCcw, Trash2, Loader2, Plus, Pencil } fro
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -508,9 +509,7 @@ export default function BackupsPage() {
<TableHeader>
<TableRow>
<TableHead className="w-8">
<input
type="checkbox"
className="h-4 w-4"
<Checkbox
checked={allVmidsSelected}
title={t('backups.select_all_one_per_vmid')}
onChange={() =>
@@ -536,9 +535,7 @@ export default function BackupsPage() {
return (
<TableRow key={i} data-selected={isSelected}>
<TableCell>
<input
type="checkbox"
className="h-4 w-4"
<Checkbox
checked={isSelected}
onChange={() => toggleBackupSelected(b)}
/>
@@ -713,8 +710,7 @@ export default function BackupsPage() {
<p className="mt-1 text-xs text-muted-foreground">{t('backups.new_vmid_hint')}</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
<Checkbox
checked={restoreStart}
onChange={(e) => setRestoreStart(e.target.checked)}
/>
@@ -722,8 +718,7 @@ export default function BackupsPage() {
</label>
{detectVmType(restoreTarget.volid) === 'qemu' && (
<label className="flex items-center gap-2">
<input
type="checkbox"
<Checkbox
checked={restoreUnique}
onChange={(e) => setRestoreUnique(e.target.checked)}
/>
@@ -819,7 +814,7 @@ export default function BackupsPage() {
</div>
<label className="flex items-center gap-2">
<input type="checkbox" className="h-4 w-4" checked={bulkStart} onChange={(e) => setBulkStart(e.target.checked)} />
<Checkbox checked={bulkStart} onChange={(e) => setBulkStart(e.target.checked)} />
<span>{t('backups.start_after_restore')}</span>
</label>
@@ -828,7 +823,7 @@ export default function BackupsPage() {
<div className="space-y-2 rounded-md bg-destructive/10 p-3">
<p className="text-xs">{t('backups.bulk_in_place_warning')}</p>
<label className="flex items-center gap-2">
<input type="checkbox" className="h-4 w-4" checked={bulkConfirm} onChange={(e) => setBulkConfirm(e.target.checked)} />
<Checkbox checked={bulkConfirm} onChange={(e) => setBulkConfirm(e.target.checked)} />
<span>{t('backups.confirm_overwrite')}</span>
</label>
</div>
@@ -921,8 +916,7 @@ export default function BackupsPage() {
<div className="flex items-center justify-between mb-1">
<Label>{t('backups.vmids')}</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
<Checkbox
className="accent-amber-500"
checked={selectAllVmids}
onChange={(e) => { setSelectAllVmids(e.target.checked); if (e.target.checked) setSelectedVmids(new Set()); }}
@@ -972,8 +966,7 @@ export default function BackupsPage() {
key={`${vm.server_id}-${vm.vmid}`}
className="flex items-center gap-2 px-2 py-1 rounded hover:bg-muted cursor-pointer text-sm"
>
<input
type="checkbox"
<Checkbox
className="accent-amber-500"
checked={checked}
disabled={selectAllVmids}
@@ -1054,8 +1047,7 @@ export default function BackupsPage() {
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
<Checkbox
checked={!!jobForm.enabled}
onChange={(e) => setJobForm({ ...jobForm, enabled: e.target.checked })}
/>

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
@@ -216,7 +217,7 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
<div className="grid grid-cols-2 gap-3">
<div><Label>{t('storages.zfs_pool')}</Label><Input value={form.pool} onChange={e => set('pool', e.target.value)} className="mt-1 font-mono" placeholder="rpool/data" /></div>
<label className="flex items-end gap-2 pb-1.5 text-sm">
<input type="checkbox" checked={form.sparse} onChange={e => set('sparse', e.target.checked)} />
<Checkbox checked={form.sparse} onChange={e => set('sparse', e.target.checked)} />
{t('storages.sparse')}
</label>
</div>
@@ -436,7 +437,7 @@ function NativeJobDialog({ serverId, open, onOpenChange, editing, backupStorages
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.enabled} onChange={e => set('enabled', e.target.checked)} />
<Checkbox checked={form.enabled} onChange={e => set('enabled', e.target.checked)} />
<span>{t('storages.enabled')}</span>
</label>
</div>

View File

@@ -7,6 +7,7 @@ import {
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@@ -362,7 +363,7 @@ function JoinWizard({
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} className="h-4 w-4" />
<Checkbox checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
{t('cluster.confirm_understand', 'I understand this is irreversible')}
</label>
@@ -435,7 +436,7 @@ function EjectDialog({
<Input type="password" value={rootpw} onChange={(e) => setRootpw(e.target.value)} className="mt-1" disabled={panelOnly} />
</div>
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={panelOnly} onChange={(e) => setPanelOnly(e.target.checked)} className="mt-0.5 h-4 w-4" />
<Checkbox checked={panelOnly} onChange={(e) => setPanelOnly(e.target.checked)} className="mt-0.5" />
<span>
{t('cluster.eject_panel_only', 'Only detach the panel entry (do not touch Proxmox)')}
<span className="block text-xs text-muted-foreground">

View File

@@ -11,7 +11,9 @@ import { useInstancesMetricsSync } from '@/hooks/use-instances'
import { useAllTasks, useActiveTaskCount } from '@/hooks/use-tasks'
import { useIPAMNetworks } from '@/hooks/use-ipam'
const CHART_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#a855f7', '#06b6d4', '#ec4899', '#84cc16']
// Theme-aware — pulls from the --chart-1..5 tokens in index.css so pie slices
// stay in sync with light/dark instead of a second, hardcoded hex palette.
const CHART_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)']
type Rec = Record<string, unknown>

View File

@@ -9,6 +9,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@@ -171,7 +172,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
{isQcow2 && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={toTemplate} onChange={(e) => setToTemplate(e.target.checked)} />
<Checkbox checked={toTemplate} onChange={(e) => setToTemplate(e.target.checked)} />
<span>Сделать VM-шаблоном после загрузки (импорт диска + cloud-init)</span>
</label>
)}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
@@ -165,7 +166,7 @@ function MirrorFormDialog({
<Input id="m-desc" value={form.description} onChange={(e) => set('description', e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} />
<Checkbox checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} />
<span>Включено (показывать в каталоге)</span>
</label>
</div>

View File

@@ -10,6 +10,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@@ -274,7 +275,7 @@ export default function AddNetworkInterfaceDialog({ open, onClose, serverId, vmi
)}
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={firewall} onChange={(e) => setFirewall(e.target.checked)} />
<Checkbox checked={firewall} onChange={(e) => setFirewall(e.target.checked)} />
{t('netif.firewall', 'Firewall')}
</label>
</div>

View File

@@ -10,6 +10,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
@@ -141,19 +142,19 @@ export default function AddPciDeviceDialog({ open, onClose, serverId, vmid, node
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={pcie} onChange={(e) => setPcie(e.target.checked)} />
<Checkbox checked={pcie} onChange={(e) => setPcie(e.target.checked)} />
{t('hw.pci_pcie', 'PCI-Express (только для q35)')}
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={allFunctions} onChange={(e) => setAllFunctions(e.target.checked)} />
<Checkbox checked={allFunctions} onChange={(e) => setAllFunctions(e.target.checked)} />
{t('hw.pci_all_functions', 'Все функции устройства')}
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={primaryGpu} onChange={(e) => setPrimaryGpu(e.target.checked)} />
<Checkbox checked={primaryGpu} onChange={(e) => setPrimaryGpu(e.target.checked)} />
{t('hw.pci_primary_gpu', 'Основная видеокарта (x-vga)')}
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={romBar} onChange={(e) => setRomBar(e.target.checked)} />
<Checkbox checked={romBar} onChange={(e) => setRomBar(e.target.checked)} />
{t('hw.pci_rombar', 'ROM-Bar')}
</label>
</div>

View File

@@ -10,6 +10,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
@@ -158,11 +159,11 @@ export default function AddPhysicalDiskDialog({ open, onClose, serverId, vmid, n
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={ssd} onChange={(e) => setSsd(e.target.checked)} />
<Checkbox checked={ssd} onChange={(e) => setSsd(e.target.checked)} />
{t('hw.disk_ssd', 'Эмулировать SSD')}
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={discard} onChange={(e) => setDiscard(e.target.checked)} />
<Checkbox checked={discard} onChange={(e) => setDiscard(e.target.checked)} />
{t('hw.disk_discard', 'Discard / TRIM')}
</label>
</div>

View File

@@ -10,6 +10,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
@@ -128,7 +129,7 @@ export default function AddUsbDeviceDialog({ open, onClose, serverId, vmid, node
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={usb3} onChange={(e) => setUsb3(e.target.checked)} />
<Checkbox checked={usb3} onChange={(e) => setUsb3(e.target.checked)} />
{t('hw.usb3', 'USB 3.0')}
</label>
</div>

View File

@@ -5,6 +5,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useNodes } from '@/hooks/use-nodes';
@@ -69,11 +70,9 @@ export default function BulkMigrateDialog({
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<Checkbox
checked={online}
onChange={(e) => setOnline(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
{t('instances.online_migration', 'Онлайн-миграция запущенных (live)')}
</label>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound, Gauge } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -451,12 +452,10 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
</Select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
<Checkbox
id="start_after"
checked={config.start_after_create}
onChange={e => setConfig(p => ({ ...p, start_after_create: e.target.checked }))}
className="rounded"
/>
<Label htmlFor="start_after">{t('wizard.start_after_create')}</Label>
</div>
@@ -612,22 +611,18 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
</Select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
<Checkbox
id="lxc_start_after"
checked={lxcConfig.start_after_create}
onChange={e => setLxcConfig(p => ({ ...p, start_after_create: e.target.checked }))}
className="rounded"
/>
<Label htmlFor="lxc_start_after">{t('wizard.start_after_create')}</Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
<Checkbox
id="lxc_unprivileged"
checked={lxcConfig.unprivileged}
onChange={e => setLxcConfig(p => ({ ...p, unprivileged: e.target.checked }))}
className="rounded"
/>
<Label htmlFor="lxc_unprivileged">{t('wizard.unprivileged')}</Label>
</div>
@@ -899,9 +894,8 @@ function SSHKeyPicker({
key={k.id}
className="flex items-start gap-2 text-xs cursor-pointer hover:bg-accent rounded px-2 py-1"
>
<input
type="checkbox"
className="h-4 w-4 mt-0.5"
<Checkbox
className="mt-0.5"
checked={selected.includes(k.id)}
onChange={() => onToggle(k.id)}
/>

View File

@@ -10,6 +10,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@@ -33,6 +34,7 @@ import {
useVMConfig,
} from '@/hooks/use-instances';
import { useNodes } from '@/hooks/use-nodes';
import { useBackupStorages, useCreateBackup } from '@/hooks/use-backups';
import { useLXCStorages } from '@/hooks/use-lxc-templates';
import { useUsers } from '@/hooks/use-users';
import { useProfile } from '@/hooks/use-settings';
@@ -49,6 +51,7 @@ export type InstanceAction =
| 'notes'
| 'execute'
| 'iso'
| 'backup'
| null;
interface Props {
@@ -76,6 +79,7 @@ export default function InstanceActionDialogs(props: Props) {
<NotesDialog open={open === 'notes'} onClose={close} {...shared} />
<ExecuteCommandDialog open={open === 'execute'} onClose={close} {...shared} />
<IsoDialog open={open === 'iso'} onClose={close} {...shared} />
<BackupDialog open={open === 'backup'} onClose={close} {...shared} />
</>
);
}
@@ -211,7 +215,7 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={full} onChange={(e) => setFull(e.target.checked)} />
<Checkbox checked={full} onChange={(e) => setFull(e.target.checked)} />
<span>Полный клон (full clone)</span>
</label>
</div>
@@ -321,7 +325,7 @@ function MigrateDialog({ open, onClose, serverId, vmid, type, node, name }: Omit
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" checked={online} onChange={(e) => setOnline(e.target.checked)} className="h-4 w-4 rounded border-input accent-primary" />
<Checkbox checked={online} onChange={(e) => setOnline(e.target.checked)} />
<span className="text-sm">{t('instances.migrate_online')}</span>
</label>
<p className="text-xs text-muted-foreground -mt-2">
@@ -743,6 +747,112 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
);
}
// ==================== Backup Now ====================
function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { data: storagesData } = useBackupStorages(open ? serverId : 0);
const createBackup = useCreateBackup();
const [storage, setStorage] = useState('');
const [mode, setMode] = useState('snapshot');
const [compress, setCompress] = useState('zstd');
// Only storages that accept backups and are visible from this VM's node
// (`nodes` empty/absent => storage is available on all nodes).
const storages = useMemo(() => {
const list = (storagesData?.storages || []) as { storage: string; type: string; content?: string; nodes?: string }[];
return list
.filter((s) => (s.content || '').includes('backup'))
.filter((s) => !s.nodes || s.nodes.split(',').includes(node));
}, [storagesData, node]);
useEffect(() => {
if (open && storages.length > 0 && !storages.some((s) => s.storage === storage)) {
setStorage(storages[0].storage);
}
}, [open, storages, storage]);
const submit = () => {
if (!storage) return;
createBackup.mutate(
{ server_id: serverId, node, vmid, storage, mode, compress },
{
onSuccess: () => {
toast.success(t('instances.backup_started', 'Бэкап запущен в фоне'));
onClose();
},
onError: (e) => toast.error(e.message),
},
);
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('instances.backup_now', 'Создать резервную копию')}</DialogTitle>
<DialogDescription>
{name || `${type === 'qemu' ? 'VM' : 'CT'} ${vmid}`} · {node}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('backups.storage', 'Хранилище')}</Label>
<Select value={storage} onValueChange={setStorage}>
<SelectTrigger>
<SelectValue placeholder={t('backups.select_storage', 'Выберите хранилище')} />
</SelectTrigger>
<SelectContent>
{storages.map((s) => (
<SelectItem key={s.storage} value={s.storage}>
{s.storage} <span className="text-muted-foreground">({s.type})</span>
</SelectItem>
))}
</SelectContent>
</Select>
{storages.length === 0 && (
<p className="text-xs text-warning">
{t('instances.backup_no_storages', 'Нет хранилищ с поддержкой бэкапов на этой ноде')}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{t('backups.mode', 'Режим')}</Label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="snapshot">Snapshot</SelectItem>
<SelectItem value="suspend">Suspend</SelectItem>
<SelectItem value="stop">Stop</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('backups.compression', 'Сжатие')}</Label>
<Select value={compress} onValueChange={setCompress}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="zstd">ZSTD</SelectItem>
<SelectItem value="gzip">GZIP</SelectItem>
<SelectItem value="lzo">LZO</SelectItem>
<SelectItem value="0">{t('backups.no_compression', 'Без сжатия')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
<Button onClick={submit} disabled={!storage || createBackup.isPending}>
{createBackup.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.backup_create', 'Создать бэкап')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ==================== Power Confirm ====================
type ActionConfig = {

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
@@ -42,12 +42,16 @@ import {
HardDrive,
Lock,
ArrowRightLeft,
Settings2,
Archive,
Cpu,
} from 'lucide-react';
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
import BulkMigrateDialog from './BulkMigrateDialog';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -120,6 +124,12 @@ function CircularProgress({ value }: { value: number }) {
);
}
// Stable row identity: real VMs keyed by server:vmid, ghost rows by task id.
// Without this row.id falls back to the array index, so live data updates
// re-assign selection checkboxes and row menus to a different VM.
const vmRowId = (vm: VMInstance) =>
vm._deployTaskId ? `ghost:${vm._deployTaskId}` : `${vm.server_id}:${vm.vmid}`;
// ==================== Row Action Menu ====================
function RowActionMenu({ vm }: { vm: VMInstance }) {
const { t } = useTranslation();
@@ -157,6 +167,9 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=settings`} />}>
<Settings2 className="mr-2 h-4 w-4" /> {t('instances.parameters', 'Параметры')}
</DropdownMenuItem>
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} target="_blank" rel="noopener noreferrer" />}>
<Terminal className="mr-2 h-4 w-4" /> {t('common.console', 'Console')}
</DropdownMenuItem>
@@ -179,9 +192,15 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDialog('migrate')}>
<ArrowRightLeft className="mr-2 h-4 w-4" /> {t('instances.migrate', 'Мигрировать')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDialog('clone')}>
<Copy className="mr-2 h-4 w-4" /> {t('common.clone', 'Клонировать')}
</DropdownMenuItem>
<DropdownMenuItem render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=${isQemu ? 'hardware' : 'settings'}`} />}>
<Cpu className="mr-2 h-4 w-4" /> {t('instances.edit_resources', 'Изменить ресурсы')}
</DropdownMenuItem>
{isQemu && (
<DropdownMenuItem onClick={() => setDialog('iso')}>
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}
@@ -199,12 +218,22 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setDialog('backup')}>
<Archive className="mr-2 h-4 w-4" /> {t('instances.backup_now', 'Создать резервную копию')}
</DropdownMenuItem>
<DropdownMenuItem render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=snapshots`} />}>
<Camera className="mr-2 h-4 w-4" /> {t('common.snapshots', 'Snapshots')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDialog('reinstall')}>
<Sparkles className="mr-2 h-4 w-4" /> {t('common.reinstall', 'Переустановить')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=destroy`} />}
>
<Trash2 className="mr-2 h-4 w-4" /> {t('common.delete', 'Удалить')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<InstanceActionDialogs
@@ -494,10 +523,11 @@ export default function InstancesPage() {
// Selected real VMs (skip ghost rows by checking _deployTaskId)
const selectedVMs = useMemo(() => {
const byId = new Map(allRows.map((vm) => [vmRowId(vm), vm]));
return Object.keys(rowSelection)
.filter((k) => rowSelection[k])
.map((idx) => allRows[Number(idx)])
.filter((vm) => vm && !vm._deployTaskId);
.map((id) => byId.get(id))
.filter((vm): vm is VMInstance => !!vm && !vm._deployTaskId);
}, [rowSelection, allRows]);
const handleBulk = (action: string) => {
@@ -588,14 +618,21 @@ export default function InstancesPage() {
};
// ==================== Table Columns ====================
// Volatile lookups are read inside cells through a ref so the `columns`
// identity stays stable. flexRender treats inline `cell` functions as React
// component types, so a new columns array remounts EVERY cell — which closes
// an open row dropdown menu the moment vms/servers/tasks data refreshes.
// Cells still see fresh values: any store/query update re-renders the table.
const cellCtx = { deployTasks, overlayByVm, bulkOverlayByVm, serverNameById, nodeLabel };
const cellCtxRef = useRef(cellCtx);
cellCtxRef.current = cellCtx;
const columns = useMemo<ColumnDef<VMInstance>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
className="rounded border-border"
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
@@ -604,9 +641,7 @@ export default function InstancesPage() {
const isGhost = !!row.original._deployTaskId;
if (isGhost) return null;
return (
<input
type="checkbox"
className="rounded border-border"
<Checkbox
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
@@ -634,7 +669,7 @@ export default function InstancesPage() {
const vm = row.original;
const isGhost = !!vm._deployTaskId;
if (isGhost) {
const task = deployTasks.find((t) => t.id === vm._deployTaskId);
const task = cellCtxRef.current.deployTasks.find((t) => t.id === vm._deployTaskId);
return (
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />
@@ -672,7 +707,7 @@ export default function InstancesPage() {
const vm = row.original;
const isGhost = !!vm._deployTaskId;
if (isGhost) {
const task = deployTasks.find((t) => t.id === vm._deployTaskId);
const task = cellCtxRef.current.deployTasks.find((t) => t.id === vm._deployTaskId);
const progress = task?.progress ?? 0;
return (
<div className="flex items-center gap-2">
@@ -685,7 +720,7 @@ export default function InstancesPage() {
);
}
const status = vm.status;
const bulkOv = vm.server_id != null ? bulkOverlayByVm.get(`${vm.server_id}:${vm.vmid}`) : undefined;
const bulkOv = vm.server_id != null ? cellCtxRef.current.bulkOverlayByVm.get(`${vm.server_id}:${vm.vmid}`) : undefined;
if (bulkOv) {
return (
<div className="flex items-center gap-2">
@@ -696,7 +731,7 @@ export default function InstancesPage() {
</div>
);
}
const overlay = vm.server_id != null ? overlayByVm.get(`${vm.server_id}:${vm.vmid}`) : undefined;
const overlay = vm.server_id != null ? cellCtxRef.current.overlayByVm.get(`${vm.server_id}:${vm.vmid}`) : undefined;
if (overlay) {
const label = overlay.kind === 'reinstall'
? t('instances.status_reinstalling', 'Reinstalling')
@@ -736,12 +771,12 @@ export default function InstancesPage() {
{
accessorKey: 'node',
filterFn: multiSelectFilter,
meta: { filter: 'select', formatOption: (v: string) => nodeLabel.get(v) ?? v },
meta: { filter: 'select', formatOption: (v: string) => cellCtxRef.current.nodeLabel.get(v) ?? v },
header: t('common.node', 'Node'),
cell: ({ row }) => {
const vm = row.original;
const label =
(vm.server_id != null ? serverNameById.get(vm.server_id) : undefined) ?? vm.node;
(vm.server_id != null ? cellCtxRef.current.serverNameById.get(vm.server_id) : undefined) ?? vm.node;
return <span className="text-muted-foreground">{label}</span>;
},
size: 100,
@@ -953,8 +988,9 @@ export default function InstancesPage() {
enableSorting: false,
},
],
// Volatile data is read via cellCtxRef (see above) — only l10n identities here.
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, deployTasks, overlayByVm, bulkOverlayByVm, bulkActionLabel, serverNameById, nodeLabel]
[t, bulkActionLabel]
);
// ==================== TanStack Table ====================
@@ -972,6 +1008,9 @@ export default function InstancesPage() {
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
initialState: { pagination: { pageSize: 25 } },
// Stable row identity across live data updates: keeps selection and open
// row menus attached to the same VM when rows are re-sorted or refreshed.
getRowId: vmRowId,
// Live WS metrics replace `data` every second; without this the table
// would snap back to page 1 on every tick.
autoResetPageIndex: false,

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Plus, Power, Shield } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@@ -77,7 +78,7 @@ export default function FirewallTab({ serverId, vmid, type, node }: Props) {
const boolOption = (key: string, label: string) => (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={Number(options[key]) === 1}
<Checkbox checked={Number(options[key]) === 1}
onChange={(e) => saveOption({ [key]: e.target.checked ? 1 : 0 })} />
{label}
</label>
@@ -140,7 +141,7 @@ export default function FirewallTab({ serverId, vmid, type, node }: Props) {
{/* Options */}
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
<label className="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={(e) => saveOption({ enable: e.target.checked ? 1 : 0 })} className="h-4 w-4 accent-primary" />
<Checkbox checked={enabled} onChange={(e) => saveOption({ enable: e.target.checked ? 1 : 0 })} />
<span className="text-sm">{t('fw.enable_guest', 'Включить фаервол инстанса')}</span>
</label>
<div className="grid grid-cols-2 gap-3">

View File

@@ -4,6 +4,7 @@ import { Cpu, Usb, HardDrive, Plus, Trash2, MemoryStick, Loader2, AlertTriangle
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@@ -21,12 +22,15 @@ import {
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { useVMConfig, useUpdateConfig, useVMStatus } from '@/hooks/use-instances';
import { useVMConfig, useUpdateConfig, useVMStatus, useMoveDisk, useAddDisk, useDetachDisk } from '@/hooks/use-instances';
import { useImageStorages } from '@/hooks/use-image-catalog';
import { useLXCStorages } from '@/hooks/use-lxc-templates';
import { useConfirm } from '@/components/shared/ConfirmDialog';
import { toast } from 'sonner';
import AddPciDeviceDialog from '../AddPciDeviceDialog';
import AddUsbDeviceDialog from '../AddUsbDeviceDialog';
import AddPhysicalDiskDialog from '../AddPhysicalDiskDialog';
import { ComputeResourcesCard, DiskResizeCard } from './ResourceCards';
interface Props {
serverId: number;
@@ -207,6 +211,270 @@ function PlatformCard({
);
}
// ==================== CPU options ====================
const CPU_TYPES = ['default', 'host', 'x86-64-v2', 'x86-64-v2-AES', 'x86-64-v3', 'x86-64-v4', 'kvm64', 'qemu64'];
function CpuOptionsCard({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const [cpuType, setCpuType] = useState('default');
const [sockets, setSockets] = useState('');
const [cpulimit, setCpulimit] = useState('');
const [cpuunits, setCpuunits] = useState('');
const [numa, setNuma] = useState(false);
useEffect(() => {
if (!config) return;
// cpu из конфига может быть "host" или "cputype=host,flags=..."
const raw = typeof config.cpu === 'string' ? config.cpu : '';
const parsed = raw.includes('cputype=') ? (raw.match(/cputype=([^,]+)/)?.[1] ?? '') : raw;
setCpuType(parsed && CPU_TYPES.includes(parsed) ? parsed : (parsed ? parsed : 'default'));
setSockets(config.sockets != null ? String(config.sockets) : '');
setCpulimit(config.cpulimit != null ? String(config.cpulimit) : '');
setCpuunits(config.cpuunits != null ? String(config.cpuunits) : '');
setNuma(Number(config.numa) === 1);
}, [config]);
const save = () => {
const updates: Record<string, unknown> = {
sockets: sockets ? Number(sockets) : 1,
numa: numa ? 1 : 0,
};
if (cpuType && cpuType !== 'default') updates.cpu = cpuType;
else updates.delete = 'cpu';
// cpulimit: 0 = без лимита; cpuunits: вес планировщика
updates.cpulimit = cpulimit ? Number(cpulimit) : 0;
if (cpuunits) updates.cpuunits = Number(cpuunits);
updateConfig.mutate(updates, {
onSuccess: () => toast.success(t('instances.cpu_saved')),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Cpu className="h-4 w-4" />{t('instances.cpu_options')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t('instances.cpu_type')}</Label>
<Select value={cpuType} onValueChange={(v) => v && setCpuType(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CPU_TYPES.map((c) => (
<SelectItem key={c} value={c}>{c === 'default' ? `default (kvm64)` : c}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('instances.cpu_type_hint')}</p>
</div>
<div className="space-y-1.5">
<Label>{t('instances.sockets')}</Label>
<Input type="number" min={1} max={8} value={sockets} onChange={(e) => setSockets(e.target.value)} placeholder="1" />
</div>
<div className="space-y-1.5">
<Label>{t('instances.cpulimit')}</Label>
<Input type="number" min={0} max={128} step="0.1" value={cpulimit} onChange={(e) => setCpulimit(e.target.value)} placeholder="0" />
<p className="text-xs text-muted-foreground">{t('instances.cpulimit_hint')}</p>
</div>
<div className="space-y-1.5">
<Label>{t('instances.cpuunits')}</Label>
<Input type="number" min={1} max={262144} value={cpuunits} onChange={(e) => setCpuunits(e.target.value)} placeholder="100" />
<p className="text-xs text-muted-foreground">{t('instances.cpuunits_hint')}</p>
</div>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<Checkbox checked={numa} onChange={(e) => setNuma(e.target.checked)} />
<span className="text-sm">{t('instances.numa')}</span>
</label>
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save')}
</Button>
<p className="text-xs text-muted-foreground">{t('instances.apply_changes_note')}</p>
</CardContent>
</Card>
);
}
// ==================== Disk management (move / add / detach) ====================
/** Достать имя хранилища из спецификации диска ("local-lvm:vm-100-disk-0,size=32G"). */
function parseDiskStorage(spec: unknown): string | null {
if (typeof spec !== 'string') return null;
return spec.split(':', 1)[0] || null;
}
/** Следующее свободное имя устройства для заданной шины (scsi/virtio/sata). */
function nextFreeDevice(bus: string, existing: string[]): string {
for (let i = 0; i < 31; i++) {
const name = `${bus}${i}`;
if (!existing.includes(name)) return name;
}
return `${bus}0`;
}
function DiskManagementCard({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const confirm = useConfirm();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: storages = [] } = useLXCStorages(serverId, node);
const moveDisk = useMoveDisk(serverId, vmid, node);
const addDisk = useAddDisk(serverId, vmid, node);
const detachDisk = useDetachDisk(serverId, vmid, node);
// Реальные дисковые устройства (без cd-rom и cloudinit)
const disks = useMemo(() => {
if (!config) return [] as string[];
const re = /^(scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
return Object.entries(config)
.filter(([k, v]) => re.test(k) && typeof v === 'string'
&& !v.includes('media=cdrom') && !v.includes('cloudinit')
&& !v.startsWith('/dev/')) // проброшенные физдиски — управляются в блоке «Проброс устройств»
.map(([k]) => k);
}, [config]);
const [moveDiskDev, setMoveDiskDev] = useState('');
const [moveStorage, setMoveStorage] = useState('');
const [moveDelete, setMoveDelete] = useState(true);
const [addBus, setAddBus] = useState('scsi');
const [addStorage, setAddStorage] = useState('');
const [addSize, setAddSize] = useState('16');
const [addSsd, setAddSsd] = useState(false);
const [addDiscard, setAddDiscard] = useState(false);
useEffect(() => {
if (disks.length && !disks.includes(moveDiskDev)) setMoveDiskDev(disks[0]);
}, [disks, moveDiskDev]);
const doMove = () => {
if (!moveDiskDev || !moveStorage) return;
moveDisk.mutate(
{ disk: moveDiskDev, target_storage: moveStorage, delete: moveDelete },
{
onSuccess: () => { toast.success(t('instances.disk_moved')); setMoveStorage(''); },
onError: (e: Error) => toast.error(e.message),
}
);
};
const doAdd = () => {
if (!addStorage || !addSize) return;
const dev = nextFreeDevice(addBus, disks);
addDisk.mutate(
{ disk: dev, storage: addStorage, size_gb: Number(addSize), ssd: addSsd, discard: addDiscard },
{
onSuccess: () => { toast.success(t('instances.disk_added', { dev })); },
onError: (e: Error) => toast.error(e.message),
}
);
};
const doDetach = async (dev: string) => {
const destroy = await confirm(t('instances.disk_detach_confirm', { dev }));
if (!destroy) return;
detachDisk.mutate(
{ disk: dev, destroy: true },
{
onSuccess: () => toast.success(t('instances.disk_detached')),
onError: (e: Error) => toast.error(e.message),
}
);
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<HardDrive className="h-4 w-4" />{t('instances.disk_management')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Список дисков */}
<div className="space-y-1.5">
{disks.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('instances.no_disks')}</p>
) : disks.map((d) => (
<div key={d} className="flex items-center justify-between rounded-md border px-3 py-1.5 text-sm">
<span className="font-mono">{d}</span>
<span className="text-xs text-muted-foreground">{parseDiskStorage(config?.[d]) ?? '—'}</span>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title={t('common.delete')} onClick={() => doDetach(d)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
{/* Move disk */}
<div className="space-y-2 border-t pt-3">
<Label className="text-xs font-medium">{t('instances.disk_move')}</Label>
<div className="grid gap-3 sm:grid-cols-2">
<Select value={moveDiskDev} onValueChange={(v) => v && setMoveDiskDev(v)}>
<SelectTrigger><SelectValue placeholder={t('instances.disk_device')} /></SelectTrigger>
<SelectContent>{disks.map((d) => <SelectItem key={d} value={d}>{d}</SelectItem>)}</SelectContent>
</Select>
<Select value={moveStorage} onValueChange={(v) => v && setMoveStorage(v)}>
<SelectTrigger><SelectValue placeholder={t('instances.migrate_target_storage')} /></SelectTrigger>
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-xs">
<Checkbox checked={moveDelete} onChange={(e) => setMoveDelete(e.target.checked)} />
<span>{t('instances.disk_move_delete')}</span>
</label>
<Button size="sm" variant="outline" onClick={doMove} disabled={!moveStorage || moveDisk.isPending}>
{moveDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.disk_move')}
</Button>
</div>
{/* Add disk */}
<div className="space-y-2 border-t pt-3">
<Label className="text-xs font-medium">{t('instances.disk_add')}</Label>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1"><Label className="text-2xs">{t('instances.disk_bus')}</Label>
<Select value={addBus} onValueChange={(v) => v && setAddBus(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="scsi">scsi</SelectItem>
<SelectItem value="virtio">virtio</SelectItem>
<SelectItem value="sata">sata</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label className="text-2xs">{t('instances.migrate_target_storage')}</Label>
<Select value={addStorage} onValueChange={(v) => v && setAddStorage(v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label className="text-2xs">{t('instances.new_size_gb')}</Label>
<Input type="number" min={1} value={addSize} onChange={(e) => setAddSize(e.target.value)} />
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-xs"><Checkbox checked={addSsd} onChange={(e) => setAddSsd(e.target.checked)} /><span>SSD</span></label>
<label className="flex items-center gap-2 text-xs"><Checkbox checked={addDiscard} onChange={(e) => setAddDiscard(e.target.checked)} /><span>Discard</span></label>
</div>
<Button size="sm" variant="outline" onClick={doAdd} disabled={!addStorage || !addSize || addDisk.isPending}>
{addDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.disk_add')}
</Button>
</div>
</CardContent>
</Card>
);
}
export default function HardwareTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
@@ -258,8 +526,16 @@ export default function HardwareTab({ serverId, vmid, type, node }: Props) {
return (
<div className="space-y-4 max-w-2xl">
<ComputeResourcesCard serverId={serverId} vmid={vmid} type={type} node={node} />
<CpuOptionsCard serverId={serverId} vmid={vmid} type={type} node={node} />
<PlatformCard serverId={serverId} vmid={vmid} type={type} node={node} config={cfg} />
<DiskManagementCard serverId={serverId} vmid={vmid} type={type} node={node} />
<DiskResizeCard serverId={serverId} vmid={vmid} type={type} node={node} />
{/* Passthrough devices */}
<Card>
<CardHeader className="pb-2">

View File

@@ -0,0 +1,266 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useVMConfig, useUpdateConfig, useResizeDisk } from '@/hooks/use-instances';
import { toast } from 'sonner';
interface Props {
serverId: number;
vmid: number;
type: string;
node: string;
}
/** Достать текущий размер диска в ГБ из строки конфига Proxmox
* (напр. "local-nvme:vm-106-disk-0.qcow2,...,size=51712M,ssd=1"). */
function parseDiskSizeGb(spec: unknown): number | null {
if (typeof spec !== 'string') return null;
const m = spec.match(/(?:^|,)size=(\d+(?:\.\d+)?)([KMGT])?/i);
if (!m) return null;
const value = parseFloat(m[1]);
const factor: Record<string, number> = { K: 1 / 1048576, M: 1 / 1024, G: 1, T: 1024 };
return value * (factor[(m[2] || 'G').toUpperCase()] ?? 1);
}
// ==================== Compute resources (cores / memory) ====================
export function ComputeResourcesCard({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const [cores, setCores] = useState('');
const [memory, setMemory] = useState('');
const currentCores = config?.cores || 0;
const currentMemory = config?.memory || 0;
const handleConfigUpdate = () => {
const updates: Record<string, unknown> = {};
if (cores && Number(cores) !== currentCores) updates.cores = Number(cores);
if (memory && Number(memory) !== currentMemory) updates.memory = Number(memory);
if (Object.keys(updates).length === 0) {
toast.info('No changes to apply');
return;
}
updateConfig.mutate(updates, {
onSuccess: () => {
toast.success('Configuration updated');
setCores('');
setMemory('');
},
onError: (err) => toast.error(err.message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Settings className="h-4 w-4" />
{t('instances.compute_resources')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="cores">{t('instances.vcpu_cores')}</Label>
<Input
id="cores"
type="number"
min={1}
max={128}
placeholder={String(currentCores)}
value={cores}
onChange={(e) => setCores(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('instances.current_cores', { count: currentCores })}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="memory">{t('instances.memory_mb')}</Label>
<Input
id="memory"
type="number"
min={128}
step={128}
placeholder={String(currentMemory)}
value={memory}
onChange={(e) => setMemory(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('instances.current_memory', { count: currentMemory })}</p>
</div>
</div>
<Button onClick={handleConfigUpdate} disabled={updateConfig.isPending} size="sm">
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.apply_changes')}
</Button>
<p className="text-xs text-muted-foreground">
{t('instances.apply_changes_note')}
</p>
</CardContent>
</Card>
);
}
// ==================== Disk resize ====================
export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const resizeDisk = useResizeDisk(serverId, vmid, type, node);
const [diskSize, setDiskSize] = useState('');
const [diskDevice, setDiskDevice] = useState('');
const [growResult, setGrowResult] = useState<{ grown: boolean; output: string } | null>(null);
// Реальные дисковые устройства из конфига Proxmox (исключая cd-rom).
// Захардкоженный scsi0 ломал ресайз на VM с другим диском (напр. sata0).
const diskDevices = useMemo(() => {
if (!config) return [] as string[];
const re = /^(rootfs|mp\d+|scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
return Object.entries(config)
.filter(([k, v]) => re.test(k) && typeof v === 'string' && !v.includes('media=cdrom'))
.map(([k]) => k);
}, [config]);
// Загрузочный диск из boot=order=... — выбираем по умолчанию.
const bootDisk = useMemo(() => {
const order = typeof config?.boot === 'string' ? config.boot : '';
const first = order.match(/order=([^;]+)/)?.[1]?.split(';')[0]?.trim();
return first && diskDevices.includes(first) ? first : undefined;
}, [config?.boot, diskDevices]);
useEffect(() => {
if (diskDevices.length === 0) return;
setDiskDevice((cur) => (diskDevices.includes(cur) ? cur : bootDisk ?? diskDevices[0]));
}, [diskDevices, bootDisk]);
// Текущий размер выбранного диска (ГБ). Proxmox не умеет уменьшать диск,
// поэтому показываем его и не даём ввести значение меньше текущего.
const currentDiskGb = useMemo(
() => (diskDevice ? parseDiskSizeGb(config?.[diskDevice]) : null),
[config, diskDevice]
);
const minDiskGb = currentDiskGb != null ? Math.ceil(currentDiskGb) : 1;
// При смене диска подставляем текущий размер как стартовое значение,
// чтобы пользователь увеличивал его, а не угадывал.
useEffect(() => {
if (currentDiskGb != null) setDiskSize(String(Math.ceil(currentDiskGb)));
}, [currentDiskGb]);
const handleDiskResize = () => {
if (!diskSize || !diskDevice) return;
if (currentDiskGb != null && Number(diskSize) <= currentDiskGb) {
toast.error(
`Новый размер должен быть больше текущего (${currentDiskGb.toFixed(1)} ГБ). Уменьшение диска Proxmox не поддерживает.`
);
return;
}
resizeDisk.mutate(
{ disk: diskDevice, size: `${diskSize}G` },
{
onSuccess: (data) => {
const res = data as { message?: string; filesystem_grown?: boolean; grow_output?: string };
const grown = !!res?.filesystem_grown;
setGrowResult({ grown, output: res?.grow_output ?? '' });
if (grown) toast.success(res?.message ?? `Disk resized to ${diskSize}G`);
else toast.warning(res?.message ?? `Диск увеличен до ${diskSize}G, но ФС не расширена`);
},
onError: (err) => toast.error(err.message),
}
);
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t('instances.disk_resize')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="disk-device">{t('instances.disk_device')}</Label>
{diskDevices.length > 0 ? (
<Select value={diskDevice} onValueChange={(v) => setDiskDevice(v ?? "")}>
<SelectTrigger id="disk-device">
<SelectValue placeholder={t('common.placeholder_disk')} />
</SelectTrigger>
<SelectContent>
{diskDevices.map((dev) => (
<SelectItem key={dev} value={dev}>
{dev}
{dev === bootDisk ? ' (boot)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="disk-device"
value={diskDevice}
onChange={(e) => setDiskDevice(e.target.value)}
placeholder={t('common.placeholder_disk')}
/>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="disk-size">{t('instances.new_size_gb')}</Label>
<Input
id="disk-size"
type="number"
min={minDiskGb}
value={diskSize}
onChange={(e) => setDiskSize(e.target.value)}
placeholder={t('common.placeholder_disk_size')}
/>
{currentDiskGb != null && (
<p className="text-xs text-muted-foreground">
Текущий размер: {currentDiskGb.toFixed(1)} ГБ
</p>
)}
</div>
</div>
<Button
onClick={handleDiskResize}
disabled={!diskSize || !diskDevice || resizeDisk.isPending}
size="sm"
variant="outline"
>
{resizeDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.resize_disk')}
</Button>
<p className="text-xs text-muted-foreground">
{t('instances.resize_disk_hint')}
</p>
{growResult && (
<div className="space-y-1.5">
<p className={`text-xs font-medium ${growResult.grown ? 'text-success' : 'text-warning'}`}>
{growResult.grown
? 'Файловая система расширена внутри ОС'
: 'Файловая система не расширена — вывод growpart ниже'}
</p>
{growResult.output && (
<pre className="max-h-48 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap">
{growResult.output}
</pre>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,16 +1,16 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings, Loader2, Power, Cpu, HardDrive, Trash2, SlidersHorizontal, LockOpen, ListOrdered, ArrowUp, ArrowDown, Cloud, Eye, EyeOff } from 'lucide-react';
import { Settings, Loader2, Power, SlidersHorizontal, LockOpen, ListOrdered, ArrowUp, ArrowDown, Cloud, Eye, EyeOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useVMConfig, useUpdateConfig, useResizeDisk, useVMOwner, useSetVMOwner, useExecuteScript, useSavedConfig, useMoveDisk, useAddDisk, useDetachDisk, useUnlockInstance, useUpdateCloudInit } from '@/hooks/use-instances';
import { useLXCStorages } from '@/hooks/use-lxc-templates';
import { useVMConfig, useUpdateConfig, useVMOwner, useSetVMOwner, useExecuteScript, useSavedConfig, useUnlockInstance, useUpdateCloudInit } from '@/hooks/use-instances';
import { useProfile } from '@/hooks/use-settings';
import { useConfirm } from '@/components/shared/ConfirmDialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { ComputeResourcesCard, DiskResizeCard } from './ResourceCards';
interface Props {
serverId: number;
@@ -21,17 +21,6 @@ interface Props {
const OWNER_NONE = '__none__';
/** Достать текущий размер диска в ГБ из строки конфига Proxmox
* (напр. "local-nvme:vm-106-disk-0.qcow2,...,size=51712M,ssd=1"). */
function parseDiskSizeGb(spec: unknown): number | null {
if (typeof spec !== 'string') return null;
const m = spec.match(/(?:^|,)size=(\d+(?:\.\d+)?)([KMGT])?/i);
if (!m) return null;
const value = parseFloat(m[1]);
const factor: Record<string, number> = { K: 1 / 1048576, M: 1 / 1024, G: 1, T: 1024 };
return value * (factor[(m[2] || 'G').toUpperCase()] ?? 1);
}
function OwnerCard({ serverId, vmid }: { serverId: number; vmid: number }) {
const { t } = useTranslation();
const { data: profile } = useProfile();
@@ -213,11 +202,9 @@ function AutostartCard({ serverId, vmid, type, node }: { serverId: number; vmid:
</CardHeader>
<CardContent className="space-y-4">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
<Checkbox
checked={onboot}
onChange={e => setOnboot(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">{t('instances.start_on_boot')}</span>
</label>
@@ -243,11 +230,9 @@ function AutostartCard({ serverId, vmid, type, node }: { serverId: number; vmid:
<p className="text-xs text-muted-foreground">{t('instances.startup_order_hint')}</p>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="checkbox"
<Checkbox
checked={protection}
onChange={e => setProtection(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">{t('instances.protection')}</span>
</label>
@@ -638,8 +623,8 @@ function OptionToggle({ label, hint, checked, onChange }: {
<span className="text-sm">{label}</span>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input accent-primary" />
<Checkbox checked={checked} onChange={(e) => onChange(e.target.checked)}
className="mt-0.5" />
</label>
);
}
@@ -724,364 +709,9 @@ function VmOptionsCard({ serverId, vmid, type, node }: { serverId: number; vmid:
);
}
// ==================== CPU options ====================
const CPU_TYPES = ['default', 'host', 'x86-64-v2', 'x86-64-v2-AES', 'x86-64-v3', 'x86-64-v4', 'kvm64', 'qemu64'];
function CpuOptionsCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const [cpuType, setCpuType] = useState('default');
const [sockets, setSockets] = useState('');
const [cpulimit, setCpulimit] = useState('');
const [cpuunits, setCpuunits] = useState('');
const [numa, setNuma] = useState(false);
useEffect(() => {
if (!config) return;
// cpu из конфига может быть "host" или "cputype=host,flags=..."
const raw = typeof config.cpu === 'string' ? config.cpu : '';
const parsed = raw.includes('cputype=') ? (raw.match(/cputype=([^,]+)/)?.[1] ?? '') : raw;
setCpuType(parsed && CPU_TYPES.includes(parsed) ? parsed : (parsed ? parsed : 'default'));
setSockets(config.sockets != null ? String(config.sockets) : '');
setCpulimit(config.cpulimit != null ? String(config.cpulimit) : '');
setCpuunits(config.cpuunits != null ? String(config.cpuunits) : '');
setNuma(Number(config.numa) === 1);
}, [config]);
const save = () => {
const updates: Record<string, unknown> = {
sockets: sockets ? Number(sockets) : 1,
numa: numa ? 1 : 0,
};
if (cpuType && cpuType !== 'default') updates.cpu = cpuType;
else updates.delete = 'cpu';
// cpulimit: 0 = без лимита; cpuunits: вес планировщика
updates.cpulimit = cpulimit ? Number(cpulimit) : 0;
if (cpuunits) updates.cpuunits = Number(cpuunits);
updateConfig.mutate(updates, {
onSuccess: () => toast.success(t('instances.cpu_saved')),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Cpu className="h-4 w-4" />{t('instances.cpu_options')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t('instances.cpu_type')}</Label>
<Select value={cpuType} onValueChange={(v) => v && setCpuType(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CPU_TYPES.map((c) => (
<SelectItem key={c} value={c}>{c === 'default' ? `default (kvm64)` : c}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('instances.cpu_type_hint')}</p>
</div>
<div className="space-y-1.5">
<Label>{t('instances.sockets')}</Label>
<Input type="number" min={1} max={8} value={sockets} onChange={(e) => setSockets(e.target.value)} placeholder="1" />
</div>
<div className="space-y-1.5">
<Label>{t('instances.cpulimit')}</Label>
<Input type="number" min={0} max={128} step="0.1" value={cpulimit} onChange={(e) => setCpulimit(e.target.value)} placeholder="0" />
<p className="text-xs text-muted-foreground">{t('instances.cpulimit_hint')}</p>
</div>
<div className="space-y-1.5">
<Label>{t('instances.cpuunits')}</Label>
<Input type="number" min={1} max={262144} value={cpuunits} onChange={(e) => setCpuunits(e.target.value)} placeholder="100" />
<p className="text-xs text-muted-foreground">{t('instances.cpuunits_hint')}</p>
</div>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" checked={numa} onChange={(e) => setNuma(e.target.checked)} className="h-4 w-4 rounded border-input accent-primary" />
<span className="text-sm">{t('instances.numa')}</span>
</label>
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save')}
</Button>
<p className="text-xs text-muted-foreground">{t('instances.apply_changes_note')}</p>
</CardContent>
</Card>
);
}
// ==================== Disk management (move / add / detach) ====================
/** Достать имя хранилища из спецификации диска ("local-lvm:vm-100-disk-0,size=32G"). */
function parseDiskStorage(spec: unknown): string | null {
if (typeof spec !== 'string') return null;
return spec.split(':', 1)[0] || null;
}
/** Следующее свободное имя устройства для заданной шины (scsi/virtio/sata). */
function nextFreeDevice(bus: string, existing: string[]): string {
for (let i = 0; i < 31; i++) {
const name = `${bus}${i}`;
if (!existing.includes(name)) return name;
}
return `${bus}0`;
}
function DiskManagementCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
const { t } = useTranslation();
const confirm = useConfirm();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: storages = [] } = useLXCStorages(serverId, node);
const moveDisk = useMoveDisk(serverId, vmid, node);
const addDisk = useAddDisk(serverId, vmid, node);
const detachDisk = useDetachDisk(serverId, vmid, node);
// Реальные дисковые устройства (без cd-rom и cloudinit)
const disks = useMemo(() => {
if (!config) return [] as string[];
const re = /^(scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
return Object.entries(config)
.filter(([k, v]) => re.test(k) && typeof v === 'string'
&& !v.includes('media=cdrom') && !v.includes('cloudinit')
&& !v.startsWith('/dev/')) // проброшенные физдиски — управляются во вкладке «Оборудование»
.map(([k]) => k);
}, [config]);
const [moveDiskDev, setMoveDiskDev] = useState('');
const [moveStorage, setMoveStorage] = useState('');
const [moveDelete, setMoveDelete] = useState(true);
const [addBus, setAddBus] = useState('scsi');
const [addStorage, setAddStorage] = useState('');
const [addSize, setAddSize] = useState('16');
const [addSsd, setAddSsd] = useState(false);
const [addDiscard, setAddDiscard] = useState(false);
useEffect(() => {
if (disks.length && !disks.includes(moveDiskDev)) setMoveDiskDev(disks[0]);
}, [disks, moveDiskDev]);
const doMove = () => {
if (!moveDiskDev || !moveStorage) return;
moveDisk.mutate(
{ disk: moveDiskDev, target_storage: moveStorage, delete: moveDelete },
{
onSuccess: () => { toast.success(t('instances.disk_moved')); setMoveStorage(''); },
onError: (e: Error) => toast.error(e.message),
}
);
};
const doAdd = () => {
if (!addStorage || !addSize) return;
const dev = nextFreeDevice(addBus, disks);
addDisk.mutate(
{ disk: dev, storage: addStorage, size_gb: Number(addSize), ssd: addSsd, discard: addDiscard },
{
onSuccess: () => { toast.success(t('instances.disk_added', { dev })); },
onError: (e: Error) => toast.error(e.message),
}
);
};
const doDetach = async (dev: string) => {
const destroy = await confirm(t('instances.disk_detach_confirm', { dev }));
if (!destroy) return;
detachDisk.mutate(
{ disk: dev, destroy: true },
{
onSuccess: () => toast.success(t('instances.disk_detached')),
onError: (e: Error) => toast.error(e.message),
}
);
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<HardDrive className="h-4 w-4" />{t('instances.disk_management')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Список дисков */}
<div className="space-y-1.5">
{disks.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('instances.no_disks')}</p>
) : disks.map((d) => (
<div key={d} className="flex items-center justify-between rounded-md border px-3 py-1.5 text-sm">
<span className="font-mono">{d}</span>
<span className="text-xs text-muted-foreground">{parseDiskStorage(config?.[d]) ?? '—'}</span>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title={t('common.delete')} onClick={() => doDetach(d)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
{/* Move disk */}
<div className="space-y-2 border-t pt-3">
<Label className="text-xs font-medium">{t('instances.disk_move')}</Label>
<div className="grid gap-3 sm:grid-cols-2">
<Select value={moveDiskDev} onValueChange={(v) => v && setMoveDiskDev(v)}>
<SelectTrigger><SelectValue placeholder={t('instances.disk_device')} /></SelectTrigger>
<SelectContent>{disks.map((d) => <SelectItem key={d} value={d}>{d}</SelectItem>)}</SelectContent>
</Select>
<Select value={moveStorage} onValueChange={(v) => v && setMoveStorage(v)}>
<SelectTrigger><SelectValue placeholder={t('instances.migrate_target_storage')} /></SelectTrigger>
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={moveDelete} onChange={(e) => setMoveDelete(e.target.checked)} />
<span>{t('instances.disk_move_delete')}</span>
</label>
<Button size="sm" variant="outline" onClick={doMove} disabled={!moveStorage || moveDisk.isPending}>
{moveDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.disk_move')}
</Button>
</div>
{/* Add disk */}
<div className="space-y-2 border-t pt-3">
<Label className="text-xs font-medium">{t('instances.disk_add')}</Label>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1"><Label className="text-2xs">{t('instances.disk_bus')}</Label>
<Select value={addBus} onValueChange={(v) => v && setAddBus(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="scsi">scsi</SelectItem>
<SelectItem value="virtio">virtio</SelectItem>
<SelectItem value="sata">sata</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label className="text-2xs">{t('instances.migrate_target_storage')}</Label>
<Select value={addStorage} onValueChange={(v) => v && setAddStorage(v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label className="text-2xs">{t('instances.new_size_gb')}</Label>
<Input type="number" min={1} value={addSize} onChange={(e) => setAddSize(e.target.value)} />
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={addSsd} onChange={(e) => setAddSsd(e.target.checked)} /><span>SSD</span></label>
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={addDiscard} onChange={(e) => setAddDiscard(e.target.checked)} /><span>Discard</span></label>
</div>
<Button size="sm" variant="outline" onClick={doAdd} disabled={!addStorage || !addSize || addDisk.isPending}>
{addDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.disk_add')}
</Button>
</div>
</CardContent>
</Card>
);
}
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const resizeDisk = useResizeDisk(serverId, vmid, type, node);
const [cores, setCores] = useState('');
const [memory, setMemory] = useState('');
const [diskSize, setDiskSize] = useState('');
const [diskDevice, setDiskDevice] = useState('');
const [growResult, setGrowResult] = useState<{ grown: boolean; output: string } | null>(null);
// Initialize from config
const currentCores = config?.cores || 0;
const currentMemory = config?.memory || 0;
// Реальные дисковые устройства из конфига Proxmox (исключая cd-rom).
// Захардкоженный scsi0 ломал ресайз на VM с другим диском (напр. sata0).
const diskDevices = useMemo(() => {
if (!config) return [] as string[];
const re = /^(rootfs|mp\d+|scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
return Object.entries(config)
.filter(([k, v]) => re.test(k) && typeof v === 'string' && !v.includes('media=cdrom'))
.map(([k]) => k);
}, [config]);
// Загрузочный диск из boot=order=... — выбираем по умолчанию.
const bootDisk = useMemo(() => {
const order = typeof config?.boot === 'string' ? config.boot : '';
const first = order.match(/order=([^;]+)/)?.[1]?.split(';')[0]?.trim();
return first && diskDevices.includes(first) ? first : undefined;
}, [config?.boot, diskDevices]);
useEffect(() => {
if (diskDevices.length === 0) return;
setDiskDevice((cur) => (diskDevices.includes(cur) ? cur : bootDisk ?? diskDevices[0]));
}, [diskDevices, bootDisk]);
// Текущий размер выбранного диска (ГБ). Proxmox не умеет уменьшать диск,
// поэтому показываем его и не даём ввести значение меньше текущего.
const currentDiskGb = useMemo(
() => (diskDevice ? parseDiskSizeGb(config?.[diskDevice]) : null),
[config, diskDevice]
);
const minDiskGb = currentDiskGb != null ? Math.ceil(currentDiskGb) : 1;
// При смене диска подставляем текущий размер как стартовое значение,
// чтобы пользователь увеличивал его, а не угадывал.
useEffect(() => {
if (currentDiskGb != null) setDiskSize(String(Math.ceil(currentDiskGb)));
}, [currentDiskGb]);
const handleConfigUpdate = () => {
const updates: Record<string, unknown> = {};
if (cores && Number(cores) !== currentCores) updates.cores = Number(cores);
if (memory && Number(memory) !== currentMemory) updates.memory = Number(memory);
if (Object.keys(updates).length === 0) {
toast.info('No changes to apply');
return;
}
updateConfig.mutate(updates, {
onSuccess: () => {
toast.success('Configuration updated');
setCores('');
setMemory('');
},
onError: (err) => toast.error(err.message),
});
};
const handleDiskResize = () => {
if (!diskSize || !diskDevice) return;
if (currentDiskGb != null && Number(diskSize) <= currentDiskGb) {
toast.error(
`Новый размер должен быть больше текущего (${currentDiskGb.toFixed(1)} ГБ). Уменьшение диска Proxmox не поддерживает.`
);
return;
}
resizeDisk.mutate(
{ disk: diskDevice, size: `${diskSize}G` },
{
onSuccess: (data) => {
const res = data as { message?: string; filesystem_grown?: boolean; grow_output?: string };
const grown = !!res?.filesystem_grown;
setGrowResult({ grown, output: res?.grow_output ?? '' });
if (grown) toast.success(res?.message ?? `Disk resized to ${diskSize}G`);
else toast.warning(res?.message ?? `Диск увеличен до ${diskSize}G, но ФС не расширена`);
},
onError: (err) => toast.error(err.message),
}
);
};
return (
<div className="space-y-6 max-w-2xl">
@@ -1093,52 +723,10 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
{type !== 'lxc' && <ExecuteScriptCard serverId={serverId} vmid={vmid} node={node} />}
{/* CPU & Memory */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Settings className="h-4 w-4" />
{t('instances.compute_resources')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="cores">{t('instances.vcpu_cores')}</Label>
<Input
id="cores"
type="number"
min={1}
max={128}
placeholder={String(currentCores)}
value={cores}
onChange={(e) => setCores(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('instances.current_cores', { count: currentCores })}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="memory">{t('instances.memory_mb')}</Label>
<Input
id="memory"
type="number"
min={128}
step={128}
placeholder={String(currentMemory)}
value={memory}
onChange={(e) => setMemory(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t('instances.current_memory', { count: currentMemory })}</p>
</div>
</div>
<Button onClick={handleConfigUpdate} disabled={updateConfig.isPending} size="sm">
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.apply_changes')}
</Button>
<p className="text-xs text-muted-foreground">
{t('instances.apply_changes_note')}
</p>
</CardContent>
</Card>
{/* Для QEMU вычислительные ресурсы, опции CPU, управление дисками и ресайз
диска живут на вкладке «Оборудование» (которой у LXC нет — поэтому
здесь эти карточки остаются только для контейнеров). */}
{type === 'lxc' && <ComputeResourcesCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{type !== 'lxc' && <CloudInitCard serverId={serverId} vmid={vmid} type={type} node={node} />}
@@ -1146,87 +734,7 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
{type !== 'lxc' && <VmOptionsCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{type !== 'lxc' && <CpuOptionsCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{type !== 'lxc' && <DiskManagementCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{/* Disk Resize */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t('instances.disk_resize')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="disk-device">{t('instances.disk_device')}</Label>
{diskDevices.length > 0 ? (
<Select value={diskDevice} onValueChange={(v) => setDiskDevice(v ?? "")}>
<SelectTrigger id="disk-device">
<SelectValue placeholder={t('common.placeholder_disk')} />
</SelectTrigger>
<SelectContent>
{diskDevices.map((dev) => (
<SelectItem key={dev} value={dev}>
{dev}
{dev === bootDisk ? ' (boot)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="disk-device"
value={diskDevice}
onChange={(e) => setDiskDevice(e.target.value)}
placeholder={t('common.placeholder_disk')}
/>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="disk-size">{t('instances.new_size_gb')}</Label>
<Input
id="disk-size"
type="number"
min={minDiskGb}
value={diskSize}
onChange={(e) => setDiskSize(e.target.value)}
placeholder={t('common.placeholder_disk_size')}
/>
{currentDiskGb != null && (
<p className="text-xs text-muted-foreground">
Текущий размер: {currentDiskGb.toFixed(1)} ГБ
</p>
)}
</div>
</div>
<Button
onClick={handleDiskResize}
disabled={!diskSize || !diskDevice || resizeDisk.isPending}
size="sm"
variant="outline"
>
{resizeDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.resize_disk')}
</Button>
<p className="text-xs text-muted-foreground">
{t('instances.resize_disk_hint')}
</p>
{growResult && (
<div className="space-y-1.5">
<p className={`text-xs font-medium ${growResult.grown ? 'text-success' : 'text-warning'}`}>
{growResult.grown
? 'Файловая система расширена внутри ОС'
: 'Файловая система не расширена — вывод growpart ниже'}
</p>
{growResult.output && (
<pre className="max-h-48 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap">
{growResult.output}
</pre>
)}
</div>
)}
</CardContent>
</Card>
{type === 'lxc' && <DiskResizeCard serverId={serverId} vmid={vmid} type={type} node={node} />}
<SavedConfigCard serverId={serverId} vmid={vmid} />

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Plus, Trash2, RotateCcw, Loader2, Camera, Info } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
@@ -209,12 +210,10 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
</div>
{type === 'qemu' && (
<div className="flex items-center gap-2">
<input
<Checkbox
id="snap-ram"
type="checkbox"
checked={includeRAM}
onChange={(e) => setIncludeRAM(e.target.checked)}
className="rounded border-border"
/>
<Label htmlFor="snap-ram" className="text-sm">Include VM memory (RAM state)</Label>
</div>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { ArrowLeft, Pencil, Trash2, Plus } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -200,11 +201,11 @@ function NetworkActions({ network }: { network: IPAMNetwork }) {
<WorkspaceSelect value={form.workspace_id} onChange={(id) => setForm(p => ({ ...p, workspace_id: id }))} />
<Field label={t('ipam.description')} full><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></Field>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" checked={form.is_default} onChange={e => setForm(p => ({ ...p, is_default: e.target.checked }))} />
<Checkbox checked={form.is_default} onChange={e => setForm(p => ({ ...p, is_default: e.target.checked }))} />
{t('ipam.is_default', 'Сеть по умолчанию для области')}
</label>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" checked={form.is_active} onChange={e => setForm(p => ({ ...p, is_active: e.target.checked }))} />
<Checkbox checked={form.is_active} onChange={e => setForm(p => ({ ...p, is_active: e.target.checked }))} />
{t('ipam.is_active')}
</label>
</div>
@@ -219,7 +220,7 @@ function NetworkActions({ network }: { network: IPAMNetwork }) {
<DialogHeader><DialogTitle>{t('ipam.delete_network')}</DialogTitle></DialogHeader>
<p className="text-sm text-muted-foreground">{t('ipam.delete_network_confirm', { name: network.name })}</p>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={force} onChange={e => setForce(e.target.checked)} />
<Checkbox checked={force} onChange={e => setForce(e.target.checked)} />
{t('ipam.force_delete')}
</label>
<DialogFooter>
@@ -339,7 +340,7 @@ function PoolsCard({ networkId, pools }: { networkId: number; pools: IPAMPool[]
<Field label={`${t('ipam.range')} (end)`}><Input value={form.range_end} onChange={e => setForm(p => ({ ...p, range_end: e.target.value }))} placeholder="10.0.0.250" /></Field>
<Field label={t('ipam.description')} full><Input value={form.description} onChange={e => setForm(p => ({ ...p, description: e.target.value }))} /></Field>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" checked={form.auto_assign} onChange={e => setForm(p => ({ ...p, auto_assign: e.target.checked }))} />
<Checkbox checked={form.auto_assign} onChange={e => setForm(p => ({ ...p, auto_assign: e.target.checked }))} />
{t('ipam.auto_assign')}
</label>
</div>

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Plus, Globe, Search } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@@ -104,7 +105,7 @@ export default function IPAMNetworksPage() {
<div><Label>Gateway</Label><Input value={form.gateway} onChange={e => setForm(p => ({ ...p, gateway: e.target.value }))} placeholder="10.0.0.1" /></div>
<WorkspaceSelect value={form.workspace_id} onChange={(id) => setForm(p => ({ ...p, workspace_id: id }))} />
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.is_default} onChange={e => setForm(p => ({ ...p, is_default: e.target.checked }))} />
<Checkbox checked={form.is_default} onChange={e => setForm(p => ({ ...p, is_default: e.target.checked }))} />
{t('ipam.is_default', 'Сеть по умолчанию для области')}
</label>
<div><Label>{t('ipam.bridge')}</Label><Input value={form.proxmox_bridge} onChange={e => setForm(p => ({ ...p, proxmox_bridge: e.target.value }))} placeholder="vmbr0" /></div>

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Plus, Trash2, RefreshCw, KeyRound, ShieldQuestion, Copy } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
@@ -99,7 +100,7 @@ function RealmDialog({ serverId, open, onClose }: { serverId: number; open: bool
<div className="space-y-1.5"><Label>{t('access.domain', 'Домен')}</Label><Input value={domain} onChange={(e) => setDomain(e.target.value)} className="font-mono" placeholder="example.com" /></div>
)}
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} />
<Checkbox checked={secure} onChange={(e) => setSecure(e.target.checked)} />
{t('access.secure', 'SSL/TLS (secure)')}
</label>
</div>
@@ -193,7 +194,7 @@ function TokenDialog({ serverId, userid, open, onClose }: { serverId: number; us
<div className="space-y-1.5"><Label>{t('access.token_id', 'ID токена')}</Label><Input value={tokenid} onChange={(e) => setTokenid(e.target.value)} className="font-mono" placeholder="automation" /></div>
<div className="space-y-1.5"><Label>{t('common.comment', 'Комментарий')}</Label><Input value={comment} onChange={(e) => setComment(e.target.value)} /></div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={privsep} onChange={(e) => setPrivsep(e.target.checked)} />
<Checkbox checked={privsep} onChange={(e) => setPrivsep(e.target.checked)} />
{t('access.privsep', 'Privilege separation (отдельные права токена)')}
</label>
</div>

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -163,7 +164,7 @@ function CreateZfsDialog({ serverId, node, disks, open, onClose }: {
? <p className="py-2 text-center text-xs text-muted-foreground">{t('disks.no_free_disks', 'Нет свободных дисков')}</p>
: available.map(d => (
<label key={d.devpath} className="flex items-center gap-2 rounded px-1 py-0.5 text-sm hover:bg-muted/50">
<input type="checkbox" checked={selected.has(d.devpath)} onChange={() => toggle(d.devpath)} />
<Checkbox checked={selected.has(d.devpath)} onChange={() => toggle(d.devpath)} />
<span className="font-mono text-xs">{d.devpath}</span>
<span className="text-xs text-muted-foreground">{formatBytes(d.size || 0)}</span>
{d.model && <span className="text-xs text-muted-foreground truncate">{d.model}</span>}

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -166,12 +167,12 @@ export function RuleDialog({
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.enable} onChange={(e) => set('enable', e.target.checked)} />
<Checkbox checked={form.enable} onChange={(e) => set('enable', e.target.checked)} />
{t('fw.enabled', 'Включено')}
</label>
{!isGroup && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.log} onChange={(e) => set('log', e.target.checked)} />
<Checkbox checked={form.log} onChange={(e) => set('log', e.target.checked)} />
{t('fw.log', 'Логировать')}
</label>
)}
@@ -562,7 +563,7 @@ export default function FirewallManager({ serverId }: { serverId: number }) {
{/* Options */}
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
<label className="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={toggleEnable} className="h-4 w-4 accent-primary" />
<Checkbox checked={enabled} onChange={toggleEnable} />
<span className="text-sm">{t('fw.enable_dc', 'Включить фаервол датацентра')}</span>
</label>
<div className="grid grid-cols-2 gap-3">

View File

@@ -4,6 +4,7 @@ import { Plus, RefreshCw, ShieldCheck, Power } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -76,8 +77,7 @@ export default function NodeFirewallManager({ serverId, nodeNames }: { serverId:
const boolOption = (key: string, label: string) => (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<Checkbox
checked={Number(options[key]) === 1}
onChange={(e) => saveOption({ [key]: e.target.checked ? 1 : 0 })}
/>
@@ -157,7 +157,7 @@ export default function NodeFirewallManager({ serverId, nodeNames }: { serverId:
{/* Options */}
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
<label className="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={(e) => saveOption({ enable: e.target.checked ? 1 : 0 })} className="h-4 w-4 accent-primary" />
<Checkbox checked={enabled} onChange={(e) => saveOption({ enable: e.target.checked ? 1 : 0 })} />
<span className="text-sm">{t('fw.enable_node', 'Включить фаервол ноды')}</span>
</label>
<div className="grid grid-cols-2 gap-3">

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
@@ -335,7 +336,7 @@ export default function NodeNetworks({ serverId, nodeNames }: { serverId: number
</div>
<div className="flex items-center gap-2">
<input id="netif_autostart" type="checkbox" checked={form.autostart} onChange={e => set('autostart', e.target.checked)} className="h-4 w-4" />
<Checkbox id="netif_autostart" checked={form.autostart} onChange={e => set('autostart', e.target.checked)} />
<Label htmlFor="netif_autostart" className="cursor-pointer">{t('netif.autostart')}</Label>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServerCredentials, useNodes } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
@@ -200,12 +201,10 @@ function ServerFormDialog({
</div>
<div className="flex items-center gap-2">
<input
<Checkbox
id="use_password"
type="checkbox"
checked={form.use_password}
onChange={e => set('use_password', e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="use_password" className="cursor-pointer">{t('nodes.use_password')}</Label>
</div>
@@ -229,12 +228,10 @@ function ServerFormDialog({
)}
<div className="flex items-center gap-2">
<input
<Checkbox
id="verify_ssl"
type="checkbox"
checked={form.verify_ssl}
onChange={e => set('verify_ssl', e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="verify_ssl" className="cursor-pointer">{t('nodes.verify_ssl')}</Label>
</div>

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -207,7 +208,7 @@ function VNetDialog({ serverId, open, onOpenChange, editing, zones }: {
<div><Label>{t('sdn.alias')}</Label><Input value={form.alias} onChange={e => set('alias', e.target.value)} className="mt-1" /></div>
</div>
<div className="flex items-center gap-2">
<input id="sdn_vlanaware" type="checkbox" checked={form.vlanaware} onChange={e => set('vlanaware', e.target.checked)} className="h-4 w-4" />
<Checkbox id="sdn_vlanaware" checked={form.vlanaware} onChange={e => set('vlanaware', e.target.checked)} />
<Label htmlFor="sdn_vlanaware" className="cursor-pointer">{t('sdn.vlanaware')}</Label>
</div>
</div>
@@ -319,11 +320,11 @@ function SubnetSection({ serverId, vnet }: { serverId: number; vnet: string }) {
<div><Label>{t('sdn.gateway')}</Label><Input value={form.gateway} onChange={e => set('gateway', e.target.value)} className="mt-1" placeholder="10.0.0.1" /></div>
<div><Label>{t('sdn.dnszoneprefix')}</Label><Input value={form.dnszoneprefix} onChange={e => set('dnszoneprefix', e.target.value)} className="mt-1" /></div>
<div className="flex items-center gap-2">
<input id="sdn_snat" type="checkbox" checked={form.snat} onChange={e => set('snat', e.target.checked)} className="h-4 w-4" />
<Checkbox id="sdn_snat" checked={form.snat} onChange={e => set('snat', e.target.checked)} />
<Label htmlFor="sdn_snat" className="cursor-pointer">{t('sdn.snat')}</Label>
</div>
<div className="flex items-center gap-2">
<input id="sdn_ipam" type="checkbox" checked={form.create_ipam_network} onChange={e => set('create_ipam_network', e.target.checked)} className="h-4 w-4" />
<Checkbox id="sdn_ipam" checked={form.create_ipam_network} onChange={e => set('create_ipam_network', e.target.checked)} />
<Label htmlFor="sdn_ipam" className="cursor-pointer">{t('sdn.create_ipam')}</Label>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { useCreateScript, useUpdateScript } from '@/hooks/use-scripts';
import type { ScriptCatalogItem, ScriptParamField } from '@/types/scripts';
@@ -161,8 +162,8 @@ export default function ScriptEditDialog({ open, onOpenChange, script }: Props)
</SelectContent>
</Select>
<label className="flex items-center justify-center gap-1 text-xs">
<input
type="checkbox" checked={p.required}
<Checkbox
checked={p.required}
onChange={(e) => setParams(params.map((x, j) => j === i ? { ...x, required: e.target.checked } : x))}
/>
{t('scripts.required', 'обяз.')}

View File

@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import {
@@ -469,7 +470,7 @@ function NotificationsTab() {
function Toggle({ id, checked, onChange, label }: { id: string; checked: boolean; onChange: (v: boolean) => void; label: string }) {
return (
<div className="flex items-center gap-2">
<input id={id} type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} className="h-4 w-4" />
<Checkbox id={id} checked={checked} onChange={e => onChange(e.target.checked)} />
<Label htmlFor={id} className="cursor-pointer">{label}</Label>
</div>
);

View File

@@ -4,6 +4,7 @@ import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useServers, useNodes, useStorages } from '@/hooks/use-nodes';
@@ -71,7 +72,7 @@ export function DeployDialog({ template, onClose }: { template: OSTemplate | nul
</div>
{min && <p className="text-xs text-muted-foreground">{t('templates.minimums')}: {min.min_cores} vCPU / {min.min_memory} MB / {min.min_disk} GB</p>}
<div className="flex items-center gap-2">
<input id="deploy_start" type="checkbox" checked={start} onChange={e => setStart(e.target.checked)} className="h-4 w-4" />
<Checkbox id="deploy_start" checked={start} onChange={e => setStart(e.target.checked)} />
<Label htmlFor="deploy_start" className="cursor-pointer">{t('templates.start_after')}</Label>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
@@ -368,11 +369,11 @@ function CreateUserDialog({ open, onOpenChange }: { open: boolean; onOpenChange:
</div>
<div className="flex gap-4 pt-1">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isActive} onChange={e => setIsActive(e.target.checked)} className="h-4 w-4" />
<Checkbox checked={isActive} onChange={e => setIsActive(e.target.checked)} />
{t('users.is_active')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isAdmin} onChange={e => setIsAdmin(e.target.checked)} className="h-4 w-4" />
<Checkbox checked={isAdmin} onChange={e => setIsAdmin(e.target.checked)} />
{t('users.is_admin')}
</label>
</div>
@@ -459,15 +460,15 @@ function EditUserDialog({
</div>
<div className="grid grid-cols-2 gap-2 pt-1">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isActive} onChange={e => setIsActive(e.target.checked)} className="h-4 w-4" />
<Checkbox checked={isActive} onChange={e => setIsActive(e.target.checked)} />
{t('users.is_active')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isAdmin} onChange={e => setIsAdmin(e.target.checked)} className="h-4 w-4" />
<Checkbox checked={isAdmin} onChange={e => setIsAdmin(e.target.checked)} />
{t('users.is_admin')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-2">
<input type="checkbox" checked={requirePwdChange} onChange={e => setRequirePwdChange(e.target.checked)} className="h-4 w-4" />
<Checkbox checked={requirePwdChange} onChange={e => setRequirePwdChange(e.target.checked)} />
{t('users.require_password_change')}
</label>
</div>
@@ -597,12 +598,10 @@ function UserServersForm({
srv.compatible ? 'cursor-pointer hover:bg-accent' : 'opacity-60 cursor-not-allowed'
}`}
>
<input
type="checkbox"
<Checkbox
checked={selected.includes(srv.id)}
onChange={() => srv.compatible && toggle(srv.id)}
disabled={!srv.compatible}
className="h-4 w-4"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{srv.name}</p>
@@ -972,7 +971,7 @@ function RoleDialog({
</div>
{mode === 'edit' && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isActive} onChange={e => setIsActive(e.target.checked)} className="h-4 w-4" />
<Checkbox checked={isActive} onChange={e => setIsActive(e.target.checked)} />
{t('users.is_active')}
</label>
)}
@@ -993,11 +992,9 @@ function RoleDialog({
<div className="grid grid-cols-2 gap-1">
{Object.entries(items).map(([code, label]) => (
<label key={code} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-accent rounded px-2 py-1">
<input
type="checkbox"
<Checkbox
checked={!!perms[code]}
onChange={() => togglePerm(code)}
className="h-4 w-4"
/>
<span className="flex-1 truncate" title={code}>
{t(`perms.label.${code.replace(/:/g, '_')}`, label as string)}
@@ -1184,19 +1181,19 @@ function SecuritySettingsForm({ initial }: { initial: SecuritySettings }) {
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-4 pt-3 border-t">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={draft.password_require_uppercase} onChange={e => setBool('password_require_uppercase', e.target.checked)} className="h-4 w-4" />
<Checkbox checked={draft.password_require_uppercase} onChange={e => setBool('password_require_uppercase', e.target.checked)} />
{t('users.password_require_uppercase')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={draft.password_require_lowercase} onChange={e => setBool('password_require_lowercase', e.target.checked)} className="h-4 w-4" />
<Checkbox checked={draft.password_require_lowercase} onChange={e => setBool('password_require_lowercase', e.target.checked)} />
{t('users.password_require_lowercase')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={draft.password_require_numbers} onChange={e => setBool('password_require_numbers', e.target.checked)} className="h-4 w-4" />
<Checkbox checked={draft.password_require_numbers} onChange={e => setBool('password_require_numbers', e.target.checked)} />
{t('users.password_require_numbers')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={draft.password_require_special} onChange={e => setBool('password_require_special', e.target.checked)} className="h-4 w-4" />
<Checkbox checked={draft.password_require_special} onChange={e => setBool('password_require_special', e.target.checked)} />
{t('users.password_require_special')}
</label>
</div>

View File

@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -193,11 +194,9 @@ function EditWorkspaceDialog({
<div className="space-y-2 max-h-60 overflow-y-auto">
{allServers.map(srv => (
<label key={srv.id} className="flex items-center gap-3 rounded-md border p-2.5 cursor-pointer hover:bg-accent">
<input
type="checkbox"
<Checkbox
checked={selectedServerIds.includes(srv.id)}
onChange={() => toggleServer(srv.id)}
className="h-4 w-4"
/>
<div className="min-w-0">
<p className="text-sm font-medium">{srv.name}</p>
@@ -219,11 +218,9 @@ function EditWorkspaceDialog({
<div className="space-y-2 max-h-60 overflow-y-auto">
{allUsers.map((u: any) => (
<label key={u.id} className="flex items-center gap-3 rounded-md border p-2.5 cursor-pointer hover:bg-accent">
<input
type="checkbox"
<Checkbox
checked={selectedUserIds.includes(u.id)}
onChange={() => toggleUser(u.id)}
className="h-4 w-4"
/>
<div className="min-w-0">
<p className="text-sm font-medium">{u.username}</p>

View File

@@ -1,18 +1,6 @@
import { Link, useLocation, Outlet, useNavigate } from 'react-router';
import {
LayoutDashboard,
Server,
Monitor,
Network,
HardDrive,
HardDriveDownload,
Archive,
Globe,
Settings,
Users,
FileText,
ClipboardList,
FolderKanban,
Sun,
Moon,
Menu,
@@ -21,9 +9,8 @@ import {
ChevronDown,
Building2,
Check,
Store,
Package,
Terminal,
Search,
FolderKanban,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
@@ -38,81 +25,15 @@ import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useAuthStore } from '@/stores/auth-store';
import { useThemeStore } from '@/stores/theme-store';
import { useCommandPaletteStore } from '@/stores/command-palette-store';
import { useWorkspaceStore } from '@/stores/workspace-store';
import { useWorkspaces } from '@/hooks/use-workspaces';
import { useGlobalRealtimeSync } from '@/hooks/use-realtime-sync';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import NotificationsDropdown from '@/components/shared/NotificationsDropdown';
interface NavItem {
label: string;
icon: React.ComponentType<{ className?: string }>;
path: string;
// Permission ("resource:action") the destination page requires. When set, the
// item is hidden unless the user is an admin or has that permission granted.
// When omitted, the item is admin-only.
permission?: string;
}
interface NavGroup {
title: string;
items: NavItem[];
}
// Each item is gated by the permission its page's API actually requires, so a
// menu only appears when the user can use the page behind it. Admins bypass all
// checks. Items without a `permission` are admin-only (no user-facing API).
const navGroups: NavGroup[] = [
{
title: 'Infrastructure',
items: [
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard', permission: 'dashboard:view' },
{ label: 'Instances', icon: Monitor, path: '/instances', permission: 'vm:view' },
{ label: 'Nodes', icon: Server, path: '/nodes', permission: 'server:view' },
{ label: 'Cluster', icon: Network, path: '/cluster', permission: 'cluster:manage' },
{ label: 'Templates', icon: HardDrive, path: '/templates', permission: 'template:view' },
{ label: 'Images', icon: HardDriveDownload, path: '/images', permission: 'template:manage' },
{ label: 'Backups', icon: Archive, path: '/backups', permission: 'backup:view' },
{ label: 'Tasks', icon: ClipboardList, path: '/tasks', permission: 'vm:view' },
],
},
{
title: 'Applications',
items: [
{ label: 'App Store', icon: Store, path: '/appstore', permission: 'app:view' },
{ label: 'My Apps', icon: Package, path: '/my-apps', permission: 'app:view' },
{ label: 'Scripts', icon: Terminal, path: '/scripts', permission: 'script:view' },
],
},
{
title: 'Network',
items: [
{ label: 'IPAM', icon: Globe, path: '/ipam', permission: 'ipam:view' },
{ label: 'Networks', icon: Network, path: '/networks', permission: 'server:manage' },
],
},
{
title: 'Management',
items: [
{ label: 'Users', icon: Users, path: '/users', permission: 'user:view' },
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' }, // admin-only
{ label: 'Logs', icon: FileText, path: '/logs', permission: 'log:view' },
{ label: 'Settings', icon: Settings, path: '/settings', permission: 'setting:view' },
],
},
];
/** Whether the current user may see a given nav item. Admins see everything. */
function canSeeNavItem(
item: NavItem,
isAdmin: boolean,
permissions: Record<string, boolean> | undefined,
): boolean {
if (isAdmin) return true;
if (!item.permission) return false; // admin-only item
return !!permissions?.[item.permission];
}
import CommandPalette from '@/components/shared/CommandPalette';
import { navGroups, canSeeNavItem } from '@/lib/nav-items';
function SidebarContent() {
const location = useLocation();
@@ -263,7 +184,9 @@ function WorkspaceSwitcher() {
function TopBar() {
const { theme, toggleTheme } = useThemeStore();
const { i18n } = useTranslation();
const { i18n, t } = useTranslation();
const setPaletteOpen = useCommandPaletteStore((s) => s.setOpen);
const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform);
const toggleLanguage = () => {
const next = i18n.language === 'ru' ? 'en' : 'ru';
@@ -271,7 +194,7 @@ function TopBar() {
};
return (
<header className="flex h-14 items-center justify-between border-b px-4 lg:px-6">
<header className="flex h-14 items-center justify-between gap-3 border-b px-4 lg:px-6">
{/* Mobile menu */}
<Sheet>
<SheetTrigger render={<Button variant="ghost" size="icon" className="lg:hidden" />}>
@@ -282,10 +205,23 @@ function TopBar() {
</SheetContent>
</Sheet>
<div className="hidden lg:block">
<div className="hidden items-center gap-3 lg:flex">
<WorkspaceSwitcher />
</div>
{/* Search / command palette trigger */}
<button
type="button"
onClick={() => setPaletteOpen(true)}
className="flex h-8 flex-1 items-center gap-2 rounded-lg border border-input bg-transparent px-2.5 text-sm text-muted-foreground transition-colors outline-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:max-w-72"
>
<Search className="h-4 w-4 shrink-0" />
<span className="hidden truncate sm:inline">{t('nav.searchPlaceholder', 'Jump to a section...')}</span>
<kbd className="ml-auto hidden shrink-0 rounded border border-border px-1.5 py-0.5 font-mono text-2xs sm:block">
{isMac ? '⌘K' : 'Ctrl+K'}
</kbd>
</button>
{/* Right side */}
<div className="flex items-center gap-1">
<Tooltip>
@@ -316,6 +252,8 @@ export default function AppLayout() {
return (
<div className="flex h-dvh overflow-hidden">
<CommandPalette />
{/* Desktop sidebar */}
<aside className="hidden w-[var(--sidebar-width)] shrink-0 border-r bg-card lg:block">
<SidebarContent />

View File

@@ -0,0 +1,89 @@
import {
LayoutDashboard,
Server,
Monitor,
Network,
HardDrive,
HardDriveDownload,
Archive,
Globe,
Settings,
Users,
FileText,
ClipboardList,
FolderKanban,
Store,
Package,
Terminal,
} from 'lucide-react';
export interface NavItem {
label: string;
icon: React.ComponentType<{ className?: string }>;
path: string;
// Permission ("resource:action") the destination page requires. When set, the
// item is hidden unless the user is an admin or has that permission granted.
// When omitted, the item is admin-only.
permission?: string;
}
export interface NavGroup {
title: string;
items: NavItem[];
}
// Each item is gated by the permission its page's API actually requires, so a
// menu only appears when the user can use the page behind it. Admins bypass all
// checks. Items without a `permission` are admin-only (no user-facing API).
// Shared by the sidebar (AppLayout) and the command palette so both stay in
// sync with a single source of truth.
export const navGroups: NavGroup[] = [
{
title: 'Infrastructure',
items: [
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard', permission: 'dashboard:view' },
{ label: 'Instances', icon: Monitor, path: '/instances', permission: 'vm:view' },
{ label: 'Nodes', icon: Server, path: '/nodes', permission: 'server:view' },
{ label: 'Cluster', icon: Network, path: '/cluster', permission: 'cluster:manage' },
{ label: 'Templates', icon: HardDrive, path: '/templates', permission: 'template:view' },
{ label: 'Images', icon: HardDriveDownload, path: '/images', permission: 'template:manage' },
{ label: 'Backups', icon: Archive, path: '/backups', permission: 'backup:view' },
{ label: 'Tasks', icon: ClipboardList, path: '/tasks', permission: 'vm:view' },
],
},
{
title: 'Applications',
items: [
{ label: 'App Store', icon: Store, path: '/appstore', permission: 'app:view' },
{ label: 'My Apps', icon: Package, path: '/my-apps', permission: 'app:view' },
{ label: 'Scripts', icon: Terminal, path: '/scripts', permission: 'script:view' },
],
},
{
title: 'Network',
items: [
{ label: 'IPAM', icon: Globe, path: '/ipam', permission: 'ipam:view' },
{ label: 'Networks', icon: Network, path: '/networks', permission: 'server:manage' },
],
},
{
title: 'Management',
items: [
{ label: 'Users', icon: Users, path: '/users', permission: 'user:view' },
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' }, // admin-only
{ label: 'Logs', icon: FileText, path: '/logs', permission: 'log:view' },
{ label: 'Settings', icon: Settings, path: '/settings', permission: 'setting:view' },
],
},
];
/** Whether the current user may see a given nav item. Admins see everything. */
export function canSeeNavItem(
item: NavItem,
isAdmin: boolean,
permissions: Record<string, boolean> | undefined,
): boolean {
if (isAdmin) return true;
if (!item.permission) return false; // admin-only item
return !!permissions?.[item.permission];
}

View File

@@ -18,7 +18,10 @@
"settings": "Settings",
"applications": "Applications",
"app store": "App Store",
"my apps": "My Apps"
"my apps": "My Apps",
"search": "Search sections",
"searchPlaceholder": "Jump to a section...",
"searchEmpty": "No results found"
},
"appstore": {
"title": "App Store",
@@ -123,7 +126,6 @@
"node": "Node (optional)",
"storage": "Storage",
"bridge": "Bridge",
"port": "Port",
"port_auto": "Default",
"ipam_network": "IPAM network (IP address)",
"ipam_dhcp": "DHCP (default)",
@@ -492,7 +494,13 @@
"rollback": "Rollback",
"delete_snapshot": "Delete",
"snapshot_storage_hint": "Snapshots require storage that supports them (qcow2, ZFS, Ceph/RBD, LVM-thin, BTRFS). They are not available on raw disks or plain LVM/iSCSI. The \"Snapshots as Volume-Chain\" option on LVM only enables snapshots for qcow2-format disks — an existing raw disk must first be moved to qcow2.",
"snapshot_unsupported_storage": "This VM's storage does not support snapshots. Use a disk on qcow2, ZFS, Ceph, LVM-thin or BTRFS — or use a backup instead."
"snapshot_unsupported_storage": "This VM's storage does not support snapshots. Use a disk on qcow2, ZFS, Ceph, LVM-thin or BTRFS — or use a backup instead.",
"parameters": "Settings",
"edit_resources": "Edit resources",
"backup_now": "Create backup",
"backup_started": "Backup started in background",
"backup_create": "Create backup",
"backup_no_storages": "No backup-capable storages on this node"
},
"wizard": {
"title": "Create Instance",
@@ -1148,7 +1156,9 @@
"every_6h": "Every 6 hours",
"weekly_sunday": "Weekly (Sunday 03:00)",
"monthly_1st": "Monthly (1st at 04:00)"
}
},
"compression": "Compression",
"no_compression": "No compression"
},
"ipam": {
"tools": "Tools",

View File

@@ -18,7 +18,10 @@
"settings": "Настройки",
"applications": "Приложения",
"app store": "Каталог приложений",
"my apps": "Мои приложения"
"my apps": "Мои приложения",
"search": "Поиск разделов",
"searchPlaceholder": "Перейти к разделу...",
"searchEmpty": "Ничего не найдено"
},
"appstore": {
"title": "Каталог приложений",
@@ -123,7 +126,6 @@
"node": "Нода (опционально)",
"storage": "Хранилище",
"bridge": "Мост",
"port": "Порт",
"port_auto": "По умолчанию",
"ipam_network": "Сеть IPAM (IP-адрес)",
"ipam_dhcp": "DHCP (по умолчанию)",
@@ -492,7 +494,13 @@
"rollback": "Откатить",
"delete_snapshot": "Удалить",
"snapshot_storage_hint": "Снимки работают только на хранилищах с их поддержкой (qcow2, ZFS, Ceph/RBD, LVM-thin, BTRFS). На raw-дисках и обычном LVM/iSCSI снимки недоступны. Опция «Snapshots as Volume-Chain» на LVM включает снимки только для дисков в формате qcow2 — существующий raw-диск нужно сначала перенести в qcow2.",
"snapshot_unsupported_storage": "Хранилище этой ВМ не поддерживает снимки. Перенесите диск на qcow2, ZFS, Ceph, LVM-thin или BTRFS — либо используйте бэкап."
"snapshot_unsupported_storage": "Хранилище этой ВМ не поддерживает снимки. Перенесите диск на qcow2, ZFS, Ceph, LVM-thin или BTRFS — либо используйте бэкап.",
"parameters": "Параметры",
"edit_resources": "Изменить ресурсы",
"backup_now": "Создать резервную копию",
"backup_started": "Бэкап запущен в фоне",
"backup_create": "Создать бэкап",
"backup_no_storages": "Нет хранилищ с поддержкой бэкапов на этой ноде"
},
"wizard": {
"title": "Создание инстанса",
@@ -1148,7 +1156,9 @@
"every_6h": "Каждые 6 часов",
"weekly_sunday": "Еженедельно (воскресенье 03:00)",
"monthly_1st": "Ежемесячно (1-е число 04:00)"
}
},
"compression": "Сжатие",
"no_compression": "Без сжатия"
},
"ipam": {
"tools": "Инструменты",
@@ -1468,10 +1478,22 @@
},
"perms": {
"role": {
"admin": { "name": "Администратор", "desc": "Полный доступ ко всем функциям" },
"moderator": { "name": "Модератор", "desc": "Управление ВМ и просмотр логов" },
"user": { "name": "Пользователь", "desc": "Стандартный пользователь с ограниченным доступом" },
"demo": { "name": "Демо-пользователь", "desc": "Доступ только для чтения (демонстрация)" }
"admin": {
"name": "Администратор",
"desc": "Полный доступ ко всем функциям"
},
"moderator": {
"name": "Модератор",
"desc": "Управление ВМ и просмотр логов"
},
"user": {
"name": "Пользователь",
"desc": "Стандартный пользователь с ограниченным доступом"
},
"demo": {
"name": "Демо-пользователь",
"desc": "Доступ только для чтения (демонстрация)"
}
},
"cat": {
"Dashboard": "Дашборд",

View File

@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface CommandPaletteState {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
}
export const useCommandPaletteStore = create<CommandPaletteState>((set) => ({
open: false,
setOpen: (open) => set({ open }),
toggle: () => set((s) => ({ open: !s.open })),
}));