Откат вкладок VM и LXC в разделе Мои VPS
This commit is contained in:
@@ -1,7 +1,23 @@
|
|||||||
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 },
|
||||||
@@ -9,52 +25,38 @@ const PRESETS = [
|
|||||||
{ key: "xlarge", label: "XL", cpu: 8, ram: 16384, disk: 100 },
|
{ key: "xlarge", label: "XL", cpu: 8, ram: 16384, disk: 100 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function matchPreset(templates, preset) {
|
function filterByOS(templates, os) {
|
||||||
return templates.find(
|
if (!os) return templates;
|
||||||
(template) =>
|
return templates.filter((template) => template.name.toLowerCase().includes(os));
|
||||||
template.cores === preset.cpu &&
|
|
||||||
template.memory_mb === preset.ram &&
|
|
||||||
template.disk_gb === preset.disk,
|
|
||||||
) || templates
|
|
||||||
.filter(
|
|
||||||
(template) =>
|
|
||||||
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 matchPreset(templates, preset) {
|
||||||
return (
|
const exact = templates.find((template) => template.cores === preset.cpu && template.memory_mb === preset.ram && template.disk_gb === preset.disk);
|
||||||
<div className={`stat-card stat-${tone}`}>
|
if (exact) return exact;
|
||||||
<div className="stat-card-body">
|
return templates.filter((template) => template.cores >= preset.cpu && template.memory_mb >= preset.ram && template.disk_gb >= preset.disk)
|
||||||
<div className="stat-card-label">{label}</div>
|
.sort((a, b) => (a.cores + a.memory_mb / 1024 + a.disk_gb) - (b.cores + b.memory_mb / 1024 + b.disk_gb))[0] || null;
|
||||||
<div className="stat-card-value">{value}</div>
|
}
|
||||||
</div>
|
|
||||||
</div>
|
function StatCard({ label, value, tone = "default", icon }) {
|
||||||
);
|
return <div className={`stat-card stat-${tone}`}><div className="stat-card-icon">{icon}</div><div className="stat-card-body"><div className="stat-card-label">{label}</div><div className="stat-card-value">{value}</div></div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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("root");
|
const [ciuser, setCiuser] = useState("ubuntu");
|
||||||
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 [instanceList, templateList] = await Promise.all([
|
const [instanceList, templateList] = await Promise.all([api.listInstances(), api.listTemplates()]);
|
||||||
api.listInstances(),
|
|
||||||
api.listTemplates(),
|
|
||||||
]);
|
|
||||||
setInstances(instanceList);
|
setInstances(instanceList);
|
||||||
setTemplates(templateList);
|
setTemplates(templateList);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -65,67 +67,42 @@ export default function Dashboard() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const typeTemplates = templates.filter((template) => template.guest_type === activeTab);
|
const osTemplates = filterByOS(templates, selectedOS);
|
||||||
const visibleInstances = instances.filter((instance) => instance.guest_type === activeTab);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPreset && activeTab === "vm") {
|
if (selectedPreset) {
|
||||||
const preset = PRESETS.find((item) => item.key === selectedPreset);
|
const preset = PRESETS.find((item) => item.key === selectedPreset);
|
||||||
const match = preset ? matchPreset(typeTemplates, preset) : null;
|
const match = preset ? matchPreset(osTemplates, preset) : null;
|
||||||
setTemplateId(match ? String(match.id) : "");
|
if (match) setTemplateId(String(match.id));
|
||||||
} else {
|
} else if (osTemplates.length) {
|
||||||
setTemplateId(typeTemplates.length ? String(typeTemplates[0].id) : "");
|
setTemplateId(String(osTemplates[0].id));
|
||||||
}
|
|
||||||
}, [activeTab, templates, selectedPreset]);
|
|
||||||
|
|
||||||
function switchTab(tab) {
|
|
||||||
setActiveTab(tab);
|
|
||||||
setSelectedPreset(null);
|
|
||||||
setError("");
|
|
||||||
}
|
}
|
||||||
|
}, [selectedOS, selectedPreset, templates]);
|
||||||
|
|
||||||
async function onCreate(event) {
|
async function onCreate(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (busy || !templateId || !name.trim()) return;
|
if (busy) return;
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.createInstance({
|
await api.createInstance({ name, template_id: Number(templateId), ciuser: ciuser || "", cipassword: cipassword || "" });
|
||||||
name: name.trim(),
|
|
||||||
template_id: Number(templateId),
|
|
||||||
// Для архивного LXC backend создаёт новый пароль root.
|
|
||||||
ciuser: activeTab === "vm" ? ciuser : "root",
|
|
||||||
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);
|
finally { setBusy(false); }
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAction(id, action) {
|
async function onAction(id, action) {
|
||||||
try {
|
try { await api.instanceAction(id, action); await refresh(); }
|
||||||
await api.instanceAction(id, action);
|
catch (err) { setError(err.message); }
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete(id) {
|
async function onDelete(id) {
|
||||||
if (!confirm("Удалить инстанс?")) return;
|
if (!confirm("Удалить VPS?")) return;
|
||||||
try {
|
try { await api.deleteInstance(id); await refresh(); }
|
||||||
await api.deleteInstance(id);
|
catch (err) { setError(err.message); }
|
||||||
await refresh();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
@@ -137,100 +114,24 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="dashboard-header">
|
<div className="dashboard-header"><div><h1 className="page-title">Мои VPS</h1><p className="page-sub">{stats.total === 0 ? "Нет активных серверов" : `${stats.running} из ${stats.total} запущено`}</p></div><button className={showForm ? "btn" : "btn btn-primary"} onClick={() => setShowForm((value) => !value)}>{showForm ? "✕ Отмена" : "+ Новый VPS"}</button></div>
|
||||||
<div>
|
|
||||||
<h1 className="page-title">Мои VPS</h1>
|
|
||||||
<p className="page-sub">
|
|
||||||
{stats.total === 0 ? "Нет активных серверов" : `${stats.running} из ${stats.total} запущено`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button className={showForm ? "btn" : "btn btn-primary"} onClick={() => setShowForm((value) => !value)}>
|
|
||||||
{showForm ? "✕ Отмена" : "+ Новый VPS"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{stats.total > 0 && (
|
{stats.total > 0 && <div className="stats-grid"><StatCard label="Всего" value={stats.total} icon={<IconServer />} /><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 className="stats-grid">
|
|
||||||
<StatCard label="Всего" value={stats.total} />
|
|
||||||
<StatCard label="Запущено" value={stats.running} tone="success" />
|
|
||||||
<StatCard label="Остановлено" value={stats.stopped} tone="muted" />
|
|
||||||
{stats.error > 0 && <StatCard label="Ошибки" value={stats.error} tone="danger" />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="nav-tabs resource-tabs">
|
{showForm && <form onSubmit={onCreate} className="card create-form">
|
||||||
<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 && (
|
|
||||||
<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><div className="os-grid">{OS_OPTIONS.map((os) => { const active = selectedOS === os.key; const available = filterByOS(templates, os.key).length > 0; return <button key={os.key} type="button" disabled={!available} onClick={() => { setSelectedOS(os.key); setSelectedPreset(null); }} className={`os-card${active ? " active" : ""}${!available ? " disabled" : ""}`}><os.logo /><div><div className="os-name">{os.label}</div><div className="os-desc">{os.desc}</div></div></button>; })}</div></div>
|
||||||
<label className="form-label">
|
<div className="form-row"><div className="field" style={{ flex: 2 }}><label>Имя сервера</label><input value={name} onChange={(event) => setName(event.target.value)} placeholder="my-server" required /></div><div className="field"><label>Пользователь</label><input value={ciuser} onChange={(event) => setCiuser(event.target.value)} placeholder="ubuntu" /></div><div className="field"><label>Пароль</label><input type="password" value={cipassword} onChange={(event) => setCipassword(event.target.value)} placeholder="••••••" /></div></div>
|
||||||
{activeTab === "vm" ? "Шаблон виртуальной машины" : "Архивный LXC-шаблон"}
|
<div className="form-section"><label className="form-label">Тариф</label><div className="preset-row"><select value={templateId} onChange={(event) => setTemplateId(event.target.value)} className="preset-select">{osTemplates.map((template) => <option key={template.id} value={template.id}>{template.name} · {template.cores} vCPU · {template.memory_mb} MB · {template.disk_gb} GB</option>)}</select><div className="preset-buttons">{PRESETS.map((preset) => { const match = matchPreset(osTemplates, preset); const available = Boolean(match); return <button key={preset.key} type="button" disabled={!available} onClick={() => { setSelectedPreset(preset.key); if (match) setTemplateId(String(match.id)); }} className={`preset-btn${selectedPreset === preset.key ? " active" : ""}${!available ? " disabled" : ""}`}><div className="preset-label">{preset.label}</div><div className="preset-status">{available ? "✓" : "—"}</div></button>; })}</div></div></div>
|
||||||
</label>
|
<button type="submit" disabled={busy || !templateId || !name} className="btn btn-primary submit-btn">{busy ? "⏳ Создаём..." : "🚀 Развернуть VPS"}</button>
|
||||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)} className="preset-select" required>
|
</form>}
|
||||||
<option value="" disabled>{typeTemplates.length ? "Выберите шаблон" : "Шаблоны не найдены"}</option>
|
|
||||||
{typeTemplates.map((template) => (
|
|
||||||
<option key={template.id} value={template.id}>
|
|
||||||
{template.name} · {template.cores} vCPU · {template.memory_mb} МБ · {template.disk_gb} ГБ
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "vm" && (
|
{loading ? <div className="empty-state">Загрузка...</div> : instances.length === 0 ? <div className="empty-state"><div className="empty-icon">☁️</div><div className="empty-title">Нет активных VPS</div><div className="empty-sub">Нажмите «+ Новый VPS» чтобы создать</div></div> : <div className="instance-grid">{instances.map((instance) => <InstanceCard key={instance.id} instance={instance} onAction={onAction} onDelete={onDelete} />)}</div>}
|
||||||
<div className="preset-buttons">
|
|
||||||
{PRESETS.map((preset) => {
|
|
||||||
const available = Boolean(matchPreset(typeTemplates, preset));
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={preset.key}
|
|
||||||
type="button"
|
|
||||||
disabled={!available}
|
|
||||||
className={`preset-btn ${selectedPreset === preset.key ? "active" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedPreset(preset.key);
|
|
||||||
const match = matchPreset(typeTemplates, preset);
|
|
||||||
if (match) setTemplateId(String(match.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<div className="field"><label>Имя сервера</label><input value={name} onChange={(event) => setName(event.target.value)} placeholder={activeTab === "lxc" ? "debian" : "my-server"} required /></div>
|
|
||||||
<div className="field"><label>{activeTab === "lxc" ? "Пользователь root" : "Пользователь"}</label><input value={ciuser} onChange={(event) => setCiuser(event.target.value)} disabled={activeTab === "lxc"} /></div>
|
|
||||||
<div className="field"><label>Новый пароль</label><input type="password" value={cipassword} onChange={(event) => setCipassword(event.target.value)} placeholder="необязательно" /></div>
|
|
||||||
</div>
|
|
||||||
{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>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? <div className="empty-state">Загрузка...</div> : visibleInstances.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-icon">☁️</div>
|
|
||||||
<div className="empty-title">Нет {activeTab === "lxc" ? "LXC-контейнеров" : "виртуальных машин"}</div>
|
|
||||||
<div className="empty-sub">Нажмите «+ Новый VPS», чтобы создать ресурс</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="instance-grid">
|
|
||||||
{visibleInstances.map((instance) => <InstanceCard key={instance.id} instance={instance} onAction={onAction} onDelete={onDelete} />)}
|
|
||||||
</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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user