fix: исправления и улучшения от 30.04.2026
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
- Обновлён backend/Dockerfile и frontend/Dockerfile - Исправлен async_ops.py (новые асинхронные операции) - Обновлён config.py - Исправлены compose.yml и compose.prod.yml - Обновлён deploy.sh - Переименованы nginx-конфиги (serverpanel → pvemanager) - Улучшены InstanceActionDialogs, InstanceDetailPage, InstancesPage - Обновлён AppLayout.tsx - Исправлен init.sql - Обновлён frontend/nginx.conf и package.json
This commit is contained in:
13
frontend/.dockerignore
Normal file
13
frontend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.md
|
||||
coverage
|
||||
.DS_Store
|
||||
@@ -1,7 +1,13 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# BuildKit-кеш npm — повторные сборки переиспользуют скачанные пакеты
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --prefer-offline --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ server {
|
||||
}
|
||||
|
||||
# Settings API proxy
|
||||
location /settings/ {
|
||||
proxy_pass http://app:8000/settings/;
|
||||
location /settings/api/ {
|
||||
proxy_pass http://app:8000/settings/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -40,8 +40,8 @@ server {
|
||||
}
|
||||
|
||||
# Templates API proxy
|
||||
location /templates/ {
|
||||
proxy_pass http://app:8000/templates/;
|
||||
location /templates/api/ {
|
||||
proxy_pass http://app:8000/templates/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -50,8 +50,8 @@ server {
|
||||
}
|
||||
|
||||
# IPAM API proxy
|
||||
location /ipam/ {
|
||||
proxy_pass http://app:8000/ipam/;
|
||||
location /ipam/api/ {
|
||||
proxy_pass http://app:8000/ipam/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -60,8 +60,8 @@ server {
|
||||
}
|
||||
|
||||
# Logs API proxy
|
||||
location /logs/ {
|
||||
proxy_pass http://app:8000/logs/;
|
||||
location /logs/api/ {
|
||||
proxy_pass http://app:8000/logs/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -70,8 +70,8 @@ server {
|
||||
}
|
||||
|
||||
# Admin API proxy
|
||||
location /admin/ {
|
||||
proxy_pass http://app:8000/admin/;
|
||||
location /admin/api/ {
|
||||
proxy_pass http://app:8000/admin/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon } from 'lucide-react';
|
||||
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon, Play, Square, RotateCcw, Power, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
|
||||
export type PowerAction = 'start' | 'stop' | 'restart' | 'shutdown';
|
||||
|
||||
export type InstanceAction =
|
||||
| 'clone'
|
||||
| 'reinstall'
|
||||
@@ -536,3 +538,105 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Power Confirm ====================
|
||||
|
||||
type ActionConfig = {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
btnLabel: string;
|
||||
btnVariant: 'default' | 'outline' | 'destructive';
|
||||
btnClass: string;
|
||||
};
|
||||
|
||||
const POWER_ACTION_CONFIG: Record<PowerAction, ActionConfig> = {
|
||||
start: {
|
||||
icon: <Play className="h-4 w-4 text-green-500" />,
|
||||
title: 'Запустить',
|
||||
description: 'Запустить инстанс',
|
||||
btnLabel: 'Запустить',
|
||||
btnVariant: 'default',
|
||||
btnClass: 'bg-green-600 hover:bg-green-700 text-white border-0',
|
||||
},
|
||||
restart: {
|
||||
icon: <RotateCcw className="h-4 w-4 text-amber-500" />,
|
||||
title: 'Перезапустить',
|
||||
description: 'Перезапустить инстанс',
|
||||
btnLabel: 'Перезапустить',
|
||||
btnVariant: 'outline',
|
||||
btnClass: 'border-amber-500 text-amber-500 hover:bg-amber-500/10',
|
||||
},
|
||||
shutdown: {
|
||||
icon: <Power className="h-4 w-4 text-orange-500" />,
|
||||
title: 'Выключить',
|
||||
description: 'Мягко выключить инстанс (ACPI shutdown)',
|
||||
btnLabel: 'Выключить',
|
||||
btnVariant: 'outline',
|
||||
btnClass: 'border-orange-500 text-orange-500 hover:bg-orange-500/10',
|
||||
},
|
||||
stop: {
|
||||
icon: <Square className="h-4 w-4" />,
|
||||
title: 'Остановить',
|
||||
description: 'Принудительно остановить инстанс. Несохранённые данные могут быть утеряны',
|
||||
btnLabel: 'Остановить',
|
||||
btnVariant: 'destructive',
|
||||
btnClass: '',
|
||||
},
|
||||
};
|
||||
|
||||
export function PowerConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
action,
|
||||
vmName,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
action: PowerAction | null;
|
||||
vmName?: string;
|
||||
isPending?: boolean;
|
||||
}) {
|
||||
if (!action) return null;
|
||||
const cfg = POWER_ACTION_CONFIG[action];
|
||||
const displayName = vmName ? `«${vmName}»` : 'выбранный инстанс';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{cfg.icon}
|
||||
{cfg.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{cfg.description} {displayName}?
|
||||
{action === 'stop' && (
|
||||
<span className="mt-2 flex items-center gap-1.5 text-destructive">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
Это аналог «выдернуть кабель из розетки».
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant={cfg.btnVariant}
|
||||
className={cfg.btnClass || undefined}
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{cfg.btnLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { useVirtualMachines, usePowerAction } from '@/hooks/use-instances';
|
||||
import InstanceActionDialogs, { type InstanceAction } from './InstanceActionDialogs';
|
||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||
import { vmTypeLabel } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -62,13 +62,25 @@ export default function InstanceDetailPage() {
|
||||
const isQemu = type === 'qemu';
|
||||
const TypeIcon = type === 'qemu' ? Monitor : Container;
|
||||
const [dialog, setDialog] = useState<InstanceAction>(null);
|
||||
const [pendingPower, setPendingPower] = useState<PowerAction | null>(null);
|
||||
|
||||
const handlePower = (action: string) => {
|
||||
setPendingPower(action as PowerAction);
|
||||
};
|
||||
|
||||
const confirmPower = () => {
|
||||
if (!pendingPower) return;
|
||||
power.mutate(
|
||||
{ action },
|
||||
{ action: pendingPower },
|
||||
{
|
||||
onSuccess: () => toast.success(`${action} sent successfully`),
|
||||
onError: (err) => toast.error(err.message),
|
||||
onSuccess: () => {
|
||||
toast.success(`${pendingPower} sent successfully`);
|
||||
setPendingPower(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
setPendingPower(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -246,6 +258,14 @@ export default function InstanceDetailPage() {
|
||||
name={vm?.name}
|
||||
description={vm?.description}
|
||||
/>
|
||||
<PowerConfirmDialog
|
||||
open={pendingPower !== null}
|
||||
onClose={() => setPendingPower(null)}
|
||||
onConfirm={confirmPower}
|
||||
action={pendingPower}
|
||||
vmName={vm?.name}
|
||||
isPending={power.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
FileText,
|
||||
Disc,
|
||||
} from 'lucide-react';
|
||||
import InstanceActionDialogs, { type InstanceAction } from './InstanceActionDialogs';
|
||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -113,13 +113,25 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
const isRunning = vm.status === 'running';
|
||||
const isQemu = vm.type === 'qemu';
|
||||
const [dialog, setDialog] = useState<InstanceAction>(null);
|
||||
const [pendingPower, setPendingPower] = useState<PowerAction | null>(null);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
setPendingPower(action as PowerAction);
|
||||
};
|
||||
|
||||
const confirmPower = () => {
|
||||
if (!pendingPower) return;
|
||||
power.mutate(
|
||||
{ action },
|
||||
{ action: pendingPower },
|
||||
{
|
||||
onSuccess: () => toast.success(`${action} sent for ${vm.name || vm.vmid}`),
|
||||
onError: (err) => toast.error(err.message),
|
||||
onSuccess: () => {
|
||||
toast.success(`${pendingPower} sent for ${vm.name || vm.vmid}`);
|
||||
setPendingPower(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
setPendingPower(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -191,6 +203,14 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
name={vm.name}
|
||||
description={vm.description}
|
||||
/>
|
||||
<PowerConfirmDialog
|
||||
open={pendingPower !== null}
|
||||
onClose={() => setPendingPower(null)}
|
||||
onConfirm={confirmPower}
|
||||
action={pendingPower}
|
||||
vmName={vm.name}
|
||||
isPending={power.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -237,7 +257,9 @@ export default function InstancesPage() {
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [nodeFilter, setNodeFilter] = useState<string>('all');
|
||||
const [nodeFilter, setNodeFilter] = useState<string>(() => {
|
||||
return localStorage.getItem('instances-node-filter') ?? 'all';
|
||||
});
|
||||
const [typeTab, setTypeTab] = useState<string>('all');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
@@ -658,7 +680,7 @@ export default function InstancesPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={nodeFilter} onValueChange={v => { if (v !== null) setNodeFilter(v); }}>
|
||||
<Select value={nodeFilter} onValueChange={v => { if (v !== null) { setNodeFilter(v); localStorage.setItem('instances-node-filter', v); } }}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder={t('common.node', 'Node')} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -87,8 +87,8 @@ function SidebarContent() {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<img src="/logo.png" alt="PVEmanager" className="h-12 w-auto" />
|
||||
<div className="flex h-20 items-center gap-2 px-4">
|
||||
<img src="/logo.png" alt="PVEmanager" className="h-16 w-auto" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
Reference in New Issue
Block a user