Добавлены отдельные вкладки VM и LXC в разделе Мои VPS

This commit is contained in:
2026-08-09 18:51:11 +03:00
parent 94185ac1e6
commit a0dd658c73
+101 -232
View File
@@ -1,41 +1,7 @@
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { api } from "../api.js"; import { api } from "../api.js";
import InstanceCard from "../components/InstanceCard.jsx"; import InstanceCard from "../components/InstanceCard.jsx";
const UbuntuLogo = () => (
<svg viewBox="0 0 64 64" width="28" height="28">
<circle cx="32" cy="32" r="28" fill="#E95420"/>
<circle cx="32" cy="32" r="22" fill="#fff"/>
<circle cx="32" cy="32" r="14" fill="#E95420"/>
<g stroke="#fff" strokeWidth="3" fill="none">
<line x1="32" y1="10" x2="32" y2="18"/>
<line x1="32" y1="46" x2="32" y2="54"/>
<line x1="10" y1="32" x2="18" y2="32"/>
<line x1="46" y1="32" x2="54" y2="32"/>
</g>
</svg>
);
const DebianLogo = () => (
<svg viewBox="0 0 64 64" width="28" height="28">
<circle cx="32" cy="32" r="28" fill="#A81D33"/>
<path d="M32 8c-5 0-10 2-14 6l6 10c2-2 5-3 8-3 7 0 13 6 13 13 0 7-6 13-13 13-3 0-6-1-8-3l-6 10c4 4 9 6 14 6 13 0 24-11 24-24S45 8 32 8z" fill="#fff" opacity="0.9"/>
</svg>
);
const CentOSLogo = () => (
<svg viewBox="0 0 64 64" width="28" height="28">
<circle cx="32" cy="32" r="28" fill="#932279"/>
<path d="M32 12l-12 8v16l12 8 12-8V20L32 12zm0 6l7 5v10l-7 5-7-5V23l7-5z" fill="#fff" opacity="0.9"/>
</svg>
);
const OS_OPTIONS = [
{ key: "ubuntu", label: "Ubuntu", desc: "Универсальный", logo: UbuntuLogo },
{ key: "debian", label: "Debian", desc: "Стабильный", logo: DebianLogo },
{ key: "centos", label: "CentOS", desc: "Корпоративный", logo: CentOSLogo },
];
const PRESETS = [ const PRESETS = [
{ key: "small", label: "S", cpu: 1, ram: 1024, disk: 10 }, { key: "small", label: "S", cpu: 1, ram: 1024, disk: 10 },
{ key: "medium", label: "M", cpu: 2, ram: 2048, disk: 20 }, { key: "medium", label: "M", cpu: 2, ram: 2048, disk: 20 },
@@ -43,23 +9,25 @@ const PRESETS = [
{ key: "xlarge", label: "XL", cpu: 8, ram: 16384, disk: 100 }, { key: "xlarge", label: "XL", cpu: 8, ram: 16384, disk: 100 },
]; ];
function filterByOS(tpl, os) { function matchPreset(templates, preset) {
if (!os) return tpl; return templates.find(
return tpl.filter(t => t.name.toLowerCase().includes(os)); (template) =>
} template.cores === preset.cpu &&
function matchPreset(tpl, p) { template.memory_mb === preset.ram &&
const exact = tpl.find(t => t.cores === p.cpu && t.memory_mb === p.ram && t.disk_gb === p.disk); template.disk_gb === preset.disk,
if (exact) return exact; ) || templates
return tpl .filter(
.filter(t => t.cores >= p.cpu && t.memory_mb >= p.ram && t.disk_gb >= p.disk) (template) =>
.sort((a, b) => (a.cores + a.memory_mb / 1024 + a.disk_gb) - (b.cores + b.memory_mb / 1024 + b.disk_gb))[0] || null; template.cores >= preset.cpu &&
template.memory_mb >= preset.ram &&
template.disk_gb >= preset.disk,
)
.sort((a, b) => a.cores + a.memory_mb / 1024 + a.disk_gb - (b.cores + b.memory_mb / 1024 + b.disk_gb))[0] || null;
} }
// Виджет-метрика на дашборде. function StatCard({ label, value, tone = "default" }) {
function StatCard({ label, value, tone = "default", icon }) {
return ( return (
<div className={`stat-card stat-${tone}`}> <div className={`stat-card stat-${tone}`}>
<div className="stat-card-icon">{icon}</div>
<div className="stat-card-body"> <div className="stat-card-body">
<div className="stat-card-label">{label}</div> <div className="stat-card-label">{label}</div>
<div className="stat-card-value">{value}</div> <div className="stat-card-value">{value}</div>
@@ -71,55 +39,68 @@ function StatCard({ label, value, tone = "default", icon }) {
export default function Dashboard() { export default function Dashboard() {
const [instances, setInstances] = useState([]); const [instances, setInstances] = useState([]);
const [templates, setTemplates] = useState([]); const [templates, setTemplates] = useState([]);
const [activeTab, setActiveTab] = useState("vm");
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [ciuser, setCiuser] = useState("ubuntu"); const [ciuser, setCiuser] = useState("root");
const [cipassword, setCipassword] = useState(""); const [cipassword, setCipassword] = useState("");
const [templateId, setTemplateId] = useState(""); const [templateId, setTemplateId] = useState("");
const [selectedOS, setSelectedOS] = useState("ubuntu");
const [selectedPreset, setSelectedPreset] = useState(null); const [selectedPreset, setSelectedPreset] = useState(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const [inst, tpl] = await Promise.all([api.listInstances(), api.listTemplates()]); const [instanceList, templateList] = await Promise.all([
setInstances(inst); api.listInstances(),
setTemplates(tpl); api.listTemplates(),
]);
setInstances(instanceList);
setTemplates(templateList);
}, []); }, []);
useEffect(() => { useEffect(() => {
refresh().finally(() => setLoading(false)); refresh().finally(() => setLoading(false));
const i = setInterval(refresh, 8000); const timer = setInterval(refresh, 8000);
return () => clearInterval(i); return () => clearInterval(timer);
}, [refresh]); }, [refresh]);
const osT = filterByOS(templates, selectedOS); const typeTemplates = templates.filter((template) => template.guest_type === activeTab);
const visibleInstances = instances.filter((instance) => instance.guest_type === activeTab);
useEffect(() => { useEffect(() => {
if (selectedPreset) { if (selectedPreset && activeTab === "vm") {
const p = PRESETS.find(x => x.key === selectedPreset); const preset = PRESETS.find((item) => item.key === selectedPreset);
const m = matchPreset(osT, p); const match = preset ? matchPreset(typeTemplates, preset) : null;
if (m) setTemplateId(String(m.id)); setTemplateId(match ? String(match.id) : "");
} else if (osT.length > 0) { } else {
setTemplateId(String(osT[0].id)); setTemplateId(typeTemplates.length ? String(typeTemplates[0].id) : "");
} }
}, [selectedOS, selectedPreset, templates]); }, [activeTab, templates, selectedPreset]);
function switchTab(tab) {
setActiveTab(tab);
setSelectedPreset(null);
setError("");
}
async function onCreate(event) {
event.preventDefault();
if (busy || !templateId || !name.trim()) return;
async function onCreate(e) {
e.preventDefault();
setError(""); setError("");
setBusy(true); setBusy(true);
try { try {
await api.createInstance({ await api.createInstance({
name, name: name.trim(),
template_id: Number(templateId), template_id: Number(templateId),
ciuser: ciuser || "", // Для архивного LXC backend создаёт новый пароль root.
ciuser: activeTab === "vm" ? ciuser : "root",
cipassword: cipassword || "", cipassword: cipassword || "",
}); });
setName(""); setName("");
setCipassword(""); setCipassword("");
setShowForm(false); setShowForm(false);
setSelectedPreset(null);
await refresh(); await refresh();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -138,7 +119,7 @@ export default function Dashboard() {
} }
async function onDelete(id) { async function onDelete(id) {
if (!confirm("Удалить VPS?")) return; if (!confirm("Удалить инстанс?")) return;
try { try {
await api.deleteInstance(id); await api.deleteInstance(id);
await refresh(); await refresh();
@@ -147,12 +128,11 @@ export default function Dashboard() {
} }
} }
// Метрики для виджетов.
const stats = { const stats = {
total: instances.length, total: instances.length,
running: instances.filter(i => i.status === "running").length, running: instances.filter((item) => item.status === "running").length,
stopped: instances.filter(i => i.status === "stopped").length, stopped: instances.filter((item) => item.status === "stopped").length,
error: instances.filter(i => i.status === "error").length, error: instances.filter((item) => item.status === "error").length,
}; };
return ( return (
@@ -161,207 +141,96 @@ export default function Dashboard() {
<div> <div>
<h1 className="page-title">Мои VPS</h1> <h1 className="page-title">Мои VPS</h1>
<p className="page-sub"> <p className="page-sub">
{stats.total === 0 {stats.total === 0 ? "Нет активных серверов" : `${stats.running} из ${stats.total} запущено`}
? "Нет активных серверов"
: `${stats.running} из ${stats.total} запущено`}
</p> </p>
</div> </div>
<button <button className={showForm ? "btn" : "btn btn-primary"} onClick={() => setShowForm((value) => !value)}>
className={showForm ? "btn" : "btn btn-primary"}
onClick={() => setShowForm(s => !s)}
>
{showForm ? "✕ Отмена" : "+ Новый VPS"} {showForm ? "✕ Отмена" : "+ Новый VPS"}
</button> </button>
</div> </div>
{/* Виджеты-метрики */}
{stats.total > 0 && ( {stats.total > 0 && (
<div className="stats-grid"> <div className="stats-grid">
<StatCard <StatCard label="Всего" value={stats.total} />
label="Всего" <StatCard label="Запущено" value={stats.running} tone="success" />
value={stats.total} <StatCard label="Остановлено" value={stats.stopped} tone="muted" />
icon={<IconServer />} {stats.error > 0 && <StatCard label="Ошибки" value={stats.error} tone="danger" />}
/>
<StatCard
label="Запущено"
value={stats.running}
tone="success"
icon={<IconPlay />}
/>
<StatCard
label="Остановлено"
value={stats.stopped}
tone="muted"
icon={<IconStop />}
/>
{stats.error > 0 && (
<StatCard
label="Ошибки"
value={stats.error}
tone="danger"
icon={<IconAlert />}
/>
)}
</div> </div>
)} )}
<div className="nav-tabs resource-tabs">
<button className={`nav-tab ${activeTab === "vm" ? "active" : ""}`} onClick={() => switchTab("vm")}>
Виртуальные машины
</button>
<button className={`nav-tab ${activeTab === "lxc" ? "active" : ""}`} onClick={() => switchTab("lxc")}>
LXC-контейнеры
</button>
</div>
{showForm && ( {showForm && (
<form onSubmit={onCreate} className="card create-form"> <form onSubmit={onCreate} className="card create-form">
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
<div className="form-section"> <div className="form-section">
<label className="form-label">Система</label> <label className="form-label">
<div className="os-grid"> {activeTab === "vm" ? "Шаблон виртуальной машины" : "Архивный LXC-шаблон"}
{OS_OPTIONS.map(os => { </label>
const active = selectedOS === os.key; <select value={templateId} onChange={(event) => setTemplateId(event.target.value)} className="preset-select" required>
const has = filterByOS(templates, os.key).length > 0; <option value="" disabled>{typeTemplates.length ? "Выберите шаблон" : "Шаблоны не найдены"}</option>
return ( {typeTemplates.map((template) => (
<button <option key={template.id} value={template.id}>
key={os.key} {template.name} · {template.cores} vCPU · {template.memory_mb} МБ · {template.disk_gb} ГБ
type="button"
disabled={!has}
onClick={() => {
setSelectedOS(os.key);
setSelectedPreset(null);
}}
className={"os-card" + (active ? " active" : "") + (!has ? " disabled" : "")}
>
<os.logo />
<div>
<div className="os-name">{os.label}</div>
<div className="os-desc">{os.desc}</div>
</div>
</button>
);
})}
</div>
</div>
<div className="form-row">
<div className="field" style={{ flex: 2 }}>
<label>Имя сервера</label>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="my-server"
required
/>
</div>
<div className="field">
<label>Пользователь</label>
<input
value={ciuser}
onChange={e => setCiuser(e.target.value)}
placeholder="ubuntu"
/>
</div>
<div className="field">
<label>Пароль</label>
<input
type="password"
value={cipassword}
onChange={e => setCipassword(e.target.value)}
placeholder="••••••"
/>
</div>
</div>
<div className="form-section">
<label className="form-label">Тариф</label>
<div className="preset-row">
<select
value={templateId}
onChange={e => setTemplateId(e.target.value)}
className="preset-select"
>
{osT.map(t => (
<option key={t.id} value={t.id}>
{t.name} · {t.cores} vCPU · {t.memory_mb} MB · {t.disk_gb} GB
</option> </option>
))} ))}
</select> </select>
</div>
{activeTab === "vm" && (
<div className="preset-buttons"> <div className="preset-buttons">
{PRESETS.map(p => { {PRESETS.map((preset) => {
const match = matchPreset(osT, p); const available = Boolean(matchPreset(typeTemplates, preset));
const avail = !!match;
const active = selectedPreset === p.key;
return ( return (
<button <button
key={p.key} key={preset.key}
type="button" type="button"
disabled={!avail} disabled={!available}
className={`preset-btn ${selectedPreset === preset.key ? "active" : ""}`}
onClick={() => { onClick={() => {
setSelectedPreset(p.key); setSelectedPreset(preset.key);
const m = matchPreset(osT, p); const match = matchPreset(typeTemplates, preset);
if (m) setTemplateId(String(m.id)); if (match) setTemplateId(String(match.id));
}} }}
className={"preset-btn" + (active ? " active" : "") + (!avail ? " disabled" : "")}
> >
<div className="preset-label">{p.label}</div> {preset.label}
<div className="preset-status">{avail ? "✓" : "—"}</div>
</button> </button>
); );
})} })}
</div> </div>
</div> )}
</div>
<button <div className="form-row">
type="submit" <div className="field"><label>Имя сервера</label><input value={name} onChange={(event) => setName(event.target.value)} placeholder={activeTab === "lxc" ? "debian" : "my-server"} required /></div>
disabled={busy || !templateId || !name} <div className="field"><label>{activeTab === "lxc" ? "Пользователь root" : "Пользователь"}</label><input value={ciuser} onChange={(event) => setCiuser(event.target.value)} disabled={activeTab === "lxc"} /></div>
className="btn btn-primary submit-btn" <div className="field"><label>Новый пароль</label><input type="password" value={cipassword} onChange={(event) => setCipassword(event.target.value)} placeholder="необязательно" /></div>
> </div>
{busy ? "⏳ Создаём..." : "🚀 Развернуть VPS"} {activeTab === "lxc" && <p className="page-sub">Для архивного шаблона Proxmox создаст новый root-пароль.</p>}
{error && <div className="error-box">{error}</div>}
<button type="submit" disabled={busy || !templateId || !name.trim()} className="btn btn-primary submit-btn">
{busy ? "⏳ Создаём…" : activeTab === "lxc" ? "🚀 Создать контейнер" : "🚀 Развернуть VM"}
</button> </button>
</form> </form>
)} )}
{loading ? ( {loading ? <div className="empty-state">Загрузка...</div> : visibleInstances.length === 0 ? (
<div className="empty-state">Загрузка...</div>
) : instances.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<div className="empty-icon"></div> <div className="empty-icon"></div>
<div className="empty-title">Нет активных VPS</div> <div className="empty-title">Нет {activeTab === "lxc" ? "LXC-контейнеров" : "виртуальных машин"}</div>
<div className="empty-sub">Нажмите «+ Новый VPS» чтобы создать</div> <div className="empty-sub">Нажмите «+ Новый VPS», чтобы создать ресурс</div>
</div> </div>
) : ( ) : (
<div className="instance-grid"> <div className="instance-grid">
{instances.map(inst => ( {visibleInstances.map((instance) => <InstanceCard key={instance.id} instance={instance} onAction={onAction} onDelete={onDelete} />)}
<InstanceCard key={inst.id} instance={inst} onAction={onAction} onDelete={onDelete} />
))}
</div> </div>
)} )}
</div> </div>
); );
} }
// Иконки для виджетов.
const IconServer = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="6" rx="1" />
<rect x="2" y="15" width="20" height="6" rx="1" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
);
const IconPlay = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="6 4 20 12 6 20 6 4" />
</svg>
);
const IconStop = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="5" width="14" height="14" rx="1" />
</svg>
);
const IconAlert = () => (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);