fix: исправления и улучшения от 30.04.2026
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:
markmorado
2026-04-30 21:58:44 +02:00
parent 79d6864d43
commit adcc86f30a
18 changed files with 403 additions and 153 deletions

13
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
dist
.git
.gitignore
.vscode
.idea
*.log
.env
.env.*
!.env.example
*.md
coverage
.DS_Store

View File

@@ -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

View File

@@ -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;

View File

@@ -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"
},

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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 />