feat(instances): command palette, unified nav, расширенное меню инстанса и правки табов
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s
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:
155
frontend/src/components/shared/CommandPalette.tsx
Normal file
155
frontend/src/components/shared/CommandPalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
64
frontend/src/components/ui/alert.tsx
Normal file
64
frontend/src/components/ui/alert.tsx
Normal 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 }
|
||||
99
frontend/src/components/ui/breadcrumb.tsx
Normal file
99
frontend/src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
25
frontend/src/components/ui/checkbox.tsx
Normal file
25
frontend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
35
frontend/src/components/ui/progress.tsx
Normal file
35
frontend/src/components/ui/progress.tsx
Normal 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 }
|
||||
23
frontend/src/components/ui/switch.tsx
Normal file
23
frontend/src/components/ui/switch.tsx
Normal 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 }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal 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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
266
frontend/src/features/instances/tabs/ResourceCards.tsx
Normal file
266
frontend/src/features/instances/tabs/ResourceCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', 'обяз.')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
89
frontend/src/lib/nav-items.ts
Normal file
89
frontend/src/lib/nav-items.ts
Normal 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];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Дашборд",
|
||||
|
||||
13
frontend/src/stores/command-palette-store.ts
Normal file
13
frontend/src/stores/command-palette-store.ts
Normal 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 })),
|
||||
}));
|
||||
Reference in New Issue
Block a user