Archived
Добавлены кнопки управления и показатели ресурсов
This commit is contained in:
+28
-69
@@ -1,87 +1,46 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import {
|
import {
|
||||||
clearToken, createInstance, createTemplate, deactivateTemplate,
|
clearToken, createInstance, createTemplate, deactivateTemplate, deleteInstance,
|
||||||
deleteInstance, getMe, getToken, listInstances, listTemplates, login, register,
|
getInstanceStats, getMe, getToken, instanceAction, listInstances, listTemplates,
|
||||||
|
login, register,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const emptyTemplate = { name: "", description: "", guest_type: "vm", source_vmid: "", source_template: "", cores: 1, memory_mb: 1024, disk_gb: 10 };
|
const emptyTemplate = { name: "", description: "", guest_type: "vm", source_vmid: "", source_template: "", cores: 1, memory_mb: 1024, disk_gb: 10 };
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
if (!value) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let number = value; let index = 0;
|
||||||
|
while (number >= 1024 && index < units.length - 1) { number /= 1024; index += 1; }
|
||||||
|
return `${number.toFixed(index > 1 ? 1 : 0)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
function Auth({ onLogin }) {
|
function Auth({ onLogin }) {
|
||||||
const [mode, setMode] = useState("login");
|
const [mode, setMode] = useState("login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [message, setMessage] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
async function submit(event) { event.preventDefault(); try { if (mode === "register") { await register(email, password); setMessage("Регистрация завершена. Теперь войдите."); setMode("login"); return; } await login(email, password); onLogin(await getMe()); } catch (error) { setMessage(error.message); } }
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [message, setMessage] = useState("");
|
|
||||||
async function submit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
try {
|
|
||||||
if (mode === "register") { await register(email, password); setMessage("Регистрация завершена. Теперь войдите."); setMode("login"); return; }
|
|
||||||
await login(email, password); onLogin(await getMe());
|
|
||||||
} catch (error) { setMessage(error.message); }
|
|
||||||
}
|
|
||||||
return <main className="page"><section className="card auth-card"><h1>Proxmox VPS Panel</h1><h2>{mode === "login" ? "Вход" : "Регистрация"}</h2><form onSubmit={submit}><label>Email<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /></label><label>Пароль<input type="password" minLength={12} value={password} onChange={(e) => setPassword(e.target.value)} required /></label><button type="submit">{mode === "login" ? "Войти" : "Зарегистрироваться"}</button></form><button className="link" type="button" onClick={() => setMode(mode === "login" ? "register" : "login")}>{mode === "login" ? "Создать аккаунт" : "У меня уже есть аккаунт"}</button>{message && <p className="message">{message}</p>}</section></main>;
|
return <main className="page"><section className="card auth-card"><h1>Proxmox VPS Panel</h1><h2>{mode === "login" ? "Вход" : "Регистрация"}</h2><form onSubmit={submit}><label>Email<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /></label><label>Пароль<input type="password" minLength={12} value={password} onChange={(e) => setPassword(e.target.value)} required /></label><button type="submit">{mode === "login" ? "Войти" : "Зарегистрироваться"}</button></form><button className="link" type="button" onClick={() => setMode(mode === "login" ? "register" : "login")}>{mode === "login" ? "Создать аккаунт" : "У меня уже есть аккаунт"}</button>{message && <p className="message">{message}</p>}</section></main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InstanceCard({ item, onChanged, onDeleted, locked }) {
|
||||||
|
const [stats, setStats] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState("");
|
||||||
|
async function loadStats() { try { setStats(await getInstanceStats(item.id)); } catch (e) { setError(e.message); } }
|
||||||
|
useEffect(() => { loadStats(); const timer = setInterval(loadStats, 10000); return () => clearInterval(timer); }, [item.id]);
|
||||||
|
async function action(action) { setBusy(true); setError(""); try { await instanceAction(item.id, action); await onChanged(); await loadStats(); } catch (e) { setError(e.message); } finally { setBusy(false); } }
|
||||||
|
async function remove() { if (!window.confirm(`Удалить ${item.name} (VMID ${item.vmid})?`)) return; setBusy(true); try { await deleteInstance(item.id); await onDeleted(); } catch (e) { setError(e.message); setBusy(false); } }
|
||||||
|
return <div className="item instance-item"><strong>{item.name}</strong><span>{item.guest_type.toUpperCase()} · VMID {item.vmid}</span><span className="muted">{stats?.status || item.status} · {item.node}</span>{stats && <div className="stats"><span>CPU: {stats.cpu_pct}%</span><span>RAM: {formatBytes(stats.memory_used_bytes)} / {formatBytes(stats.memory_total_bytes)}</span><span>Диск: {formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)}</span></div>}<div className="actions">{(stats?.status !== "running") && <button disabled={busy || locked} onClick={() => action("start")}>Старт</button>}{stats?.status === "running" && <button disabled={busy || locked} onClick={() => action("shutdown")}>Стоп</button>}{<button disabled={busy || locked} onClick={() => action("reboot")}>Перезапуск</button>}<button className="danger" disabled={busy || locked} onClick={remove}>{busy ? "Выполняется..." : "Удалить"}</button></div>{error && <span className="error-text">{error}</span>}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null); const [message, setMessage] = useState(""); const [templates, setTemplates] = useState([]); const [instances, setInstances] = useState([]); const [creating, setCreating] = useState(false); const [templateForm, setTemplateForm] = useState(emptyTemplate); const [instanceForm, setInstanceForm] = useState({ name: "", template_id: "", username: "paneluser", password: "" });
|
||||||
const [message, setMessage] = useState("");
|
async function refresh() { const [ts, ins] = await Promise.all([listTemplates(), listInstances()]); setTemplates(ts); setInstances(ins); setInstanceForm((current) => ({ ...current, template_id: current.template_id || String(ts[0]?.id || "") })); }
|
||||||
const [templates, setTemplates] = useState([]);
|
|
||||||
const [instances, setInstances] = useState([]);
|
|
||||||
const [templateForm, setTemplateForm] = useState(emptyTemplate);
|
|
||||||
const [instanceForm, setInstanceForm] = useState({ name: "", template_id: "", username: "paneluser", password: "" });
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [deletingId, setDeletingId] = useState(null);
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
const [templateData, instanceData] = await Promise.all([listTemplates(), listInstances()]);
|
|
||||||
setTemplates(templateData); setInstances(instanceData);
|
|
||||||
setInstanceForm((current) => ({ ...current, template_id: current.template_id || String(templateData[0]?.id || "") }));
|
|
||||||
}
|
|
||||||
useEffect(() => { if (!getToken()) return; Promise.all([getMe(), refresh()]).then(([current]) => setUser(current)).catch(() => clearToken()); }, []);
|
useEffect(() => { if (!getToken()) return; Promise.all([getMe(), refresh()]).then(([current]) => setUser(current)).catch(() => clearToken()); }, []);
|
||||||
|
async function submitInstance(event) { event.preventDefault(); if (creating) return; setCreating(true); setMessage("Создание VPS выполняется. Повторные клики заблокированы..."); try { const result = await createInstance({ ...instanceForm, template_id: Number(instanceForm.template_id) }); setMessage(`Создан VMID ${result.vmid}. Пароль: ${result.initial_password}`); setInstanceForm((v) => ({ ...v, name: "", password: "" })); await refresh(); } catch (e) { setMessage(e.message); } finally { setCreating(false); } }
|
||||||
async function submitTemplate(event) {
|
async function submitTemplate(event) { event.preventDefault(); try { await createTemplate({ ...templateForm, source_vmid: templateForm.guest_type === "vm" ? Number(templateForm.source_vmid) : null, source_template: templateForm.guest_type === "lxc" ? templateForm.source_template : null, cores: Number(templateForm.cores), memory_mb: Number(templateForm.memory_mb), disk_gb: Number(templateForm.disk_gb) }); setTemplateForm(emptyTemplate); setMessage("Шаблон создан."); await refresh(); } catch (e) { setMessage(e.message); } }
|
||||||
event.preventDefault();
|
async function deactivate(id) { if (!window.confirm("Деактивировать шаблон?")) return; try { await deactivateTemplate(id); await refresh(); } catch (e) { setMessage(e.message); } }
|
||||||
try {
|
|
||||||
await createTemplate({ ...templateForm, source_vmid: templateForm.guest_type === "vm" ? Number(templateForm.source_vmid) : null, source_template: templateForm.guest_type === "lxc" ? templateForm.source_template : null, cores: Number(templateForm.cores), memory_mb: Number(templateForm.memory_mb), disk_gb: Number(templateForm.disk_gb) });
|
|
||||||
setTemplateForm(emptyTemplate); setMessage("Шаблон создан."); await refresh();
|
|
||||||
} catch (error) { setMessage(error.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitInstance(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (creating) return;
|
|
||||||
setCreating(true); setMessage("Создание VPS выполняется. Не нажимайте кнопку повторно...");
|
|
||||||
try {
|
|
||||||
const result = await createInstance({ ...instanceForm, template_id: Number(instanceForm.template_id) });
|
|
||||||
setMessage(`Инстанс создан: VMID ${result.vmid}. Пароль: ${result.initial_password}`);
|
|
||||||
setInstanceForm((current) => ({ ...current, name: "", password: "" }));
|
|
||||||
await refresh();
|
|
||||||
} catch (error) { setMessage(error.message); }
|
|
||||||
finally { setCreating(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeInstance(item) {
|
|
||||||
if (deletingId || creating) return;
|
|
||||||
if (!window.confirm(`Удалить ${item.name} (VMID ${item.vmid}) из Proxmox? Это действие необратимо.`)) return;
|
|
||||||
setDeletingId(item.id); setMessage(`Удаление ${item.name} выполняется...`);
|
|
||||||
try { await deleteInstance(item.id); setMessage("Инстанс удалён."); await refresh(); }
|
|
||||||
catch (error) { setMessage(error.message); }
|
|
||||||
finally { setDeletingId(null); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deactivate(id) {
|
|
||||||
if (!window.confirm("Деактивировать шаблон?")) return;
|
|
||||||
try { await deactivateTemplate(id); await refresh(); } catch (error) { setMessage(error.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return <Auth onLogin={setUser} />;
|
if (!user) return <Auth onLogin={setUser} />;
|
||||||
return <main className="page dashboard"><header className="topbar"><div><strong>Proxmox VPS Panel</strong><span className="muted">{user.email} · {user.role}</span></div><button onClick={() => { clearToken(); setUser(null); }}>Выйти</button></header>{message && <p className="message">{message}</p>}
|
return <main className="page dashboard"><header className="topbar"><div><strong>Proxmox VPS Panel</strong><span className="muted">{user.email} · {user.role}</span></div><button onClick={() => { clearToken(); setUser(null); }}>Выйти</button></header>{message && <p className="message">{message}</p>}<section className="grid"><article className="card"><h2>Мои инстансы</h2>{instances.length === 0 ? <p className="muted">Инстансов пока нет.</p> : <div className="items">{instances.map((item) => <InstanceCard key={item.id} item={item} locked={creating} onChanged={refresh} onDeleted={refresh} />)}</div>}</article><article className="card"><h2>Доступные шаблоны</h2>{templates.length === 0 ? <p className="muted">Шаблонов пока нет.</p> : <div className="items">{templates.map((item) => <div className="item" key={item.id}><strong>{item.name}</strong><span>{item.guest_type.toUpperCase()} · {item.cores} CPU · {item.memory_mb} MB · {item.disk_gb} GB</span>{user.role === "admin" && <button className="danger" onClick={() => deactivate(item.id)}>Деактивировать</button>}</div>)}</div>}</article></section><section className="card"><h2>Создать VPS</h2><p className="muted">Повторная отправка заблокирована до завершения операции.</p><form onSubmit={submitInstance}><label>Имя VPS<input pattern="[a-zA-Z0-9][a-zA-Z0-9-]*" value={instanceForm.name} onChange={(e) => setInstanceForm({ ...instanceForm, name: e.target.value })} required /></label><label>Шаблон<select value={instanceForm.template_id} onChange={(e) => setInstanceForm({ ...instanceForm, template_id: e.target.value })} required><option value="">Выберите шаблон</option>{templates.map((item) => <option key={item.id} value={item.id}>{item.name} ({item.guest_type.toUpperCase()})</option>)}</select></label><div className="form-row"><label>Пользователь<input value={instanceForm.username} onChange={(e) => setInstanceForm({ ...instanceForm, username: e.target.value })} required /></label><label>Пароль<input type="password" minLength={12} value={instanceForm.password} onChange={(e) => setInstanceForm({ ...instanceForm, password: e.target.value })} required /></label></div><button type="submit" disabled={!templates.length || creating}>{creating ? "Создание VPS..." : "Создать VPS"}</button></form></section>{user.role === "admin" && <section className="card"><h2>Создать шаблон</h2><form onSubmit={submitTemplate}><label>Имя<input value={templateForm.name} onChange={(e) => setTemplateForm({ ...templateForm, name: e.target.value })} required /></label><label>Описание<input value={templateForm.description} onChange={(e) => setTemplateForm({ ...templateForm, description: e.target.value })} /></label><label>Тип<select value={templateForm.guest_type} onChange={(e) => setTemplateForm({ ...templateForm, guest_type: e.target.value })}><option value="vm">VM</option><option value="lxc">LXC</option></select></label>{templateForm.guest_type === "vm" ? <label>Source VMID<input type="number" min="1" value={templateForm.source_vmid} onChange={(e) => setTemplateForm({ ...templateForm, source_vmid: e.target.value })} required /></label> : <label>CT template volume<input value={templateForm.source_template} onChange={(e) => setTemplateForm({ ...templateForm, source_template: e.target.value })} required /></label>}<div className="form-row"><label>CPU<input type="number" min="1" max="64" value={templateForm.cores} onChange={(e) => setTemplateForm({ ...templateForm, cores: e.target.value })} /></label><label>RAM MB<input type="number" min="128" value={templateForm.memory_mb} onChange={(e) => setTemplateForm({ ...templateForm, memory_mb: e.target.value })} /></label><label>Диск GB<input type="number" min="1" value={templateForm.disk_gb} onChange={(e) => setTemplateForm({ ...templateForm, disk_gb: e.target.value })} /></label></div><button type="submit">Создать шаблон</button></form></section>}</main>;
|
||||||
<section className="grid"><article className="card"><h2>Мои инстансы</h2>{instances.length === 0 ? <p className="muted">Инстансов пока нет.</p> : <div className="items">{instances.map((item) => <div className="item" key={item.id}><strong>{item.name}</strong><span>{item.guest_type.toUpperCase()} · VMID {item.vmid ?? "не назначен"}</span><span className="muted">{item.status} · {item.node}</span>{item.vmid && <button className="danger" disabled={deletingId === item.id || creating} onClick={() => removeInstance(item)}>{deletingId === item.id ? "Удаление..." : "Удалить инстанс"}</button>}</div>)}</div>}</article>
|
|
||||||
<article className="card"><h2>Доступные шаблоны</h2>{templates.length === 0 ? <p className="muted">Шаблонов пока нет.</p> : <div className="items">{templates.map((item) => <div className="item" key={item.id}><strong>{item.name}</strong><span>{item.guest_type.toUpperCase()} · {item.cores} CPU · {item.memory_mb} MB · {item.disk_gb} GB</span>{user.role === "admin" && <button className="danger" onClick={() => deactivate(item.id)}>Деактивировать</button>}</div>)}</div>}</article></section>
|
|
||||||
<section className="card"><h2>Создать VPS</h2><p className="muted">Операция может занимать несколько минут. Повторная отправка заблокирована до завершения операции.</p><form onSubmit={submitInstance}><label>Имя VPS<input pattern="[a-zA-Z0-9][a-zA-Z0-9-]*" value={instanceForm.name} onChange={(e) => setInstanceForm({ ...instanceForm, name: e.target.value })} required /></label><label>Шаблон<select value={instanceForm.template_id} onChange={(e) => setInstanceForm({ ...instanceForm, template_id: e.target.value })} required><option value="">Выберите шаблон</option>{templates.map((item) => <option key={item.id} value={item.id}>{item.name} ({item.guest_type.toUpperCase()})</option>)}</select></label><div className="form-row"><label>Пользователь<input value={instanceForm.username} onChange={(e) => setInstanceForm({ ...instanceForm, username: e.target.value })} required /></label><label>Пароль<input type="password" minLength={12} value={instanceForm.password} onChange={(e) => setInstanceForm({ ...instanceForm, password: e.target.value })} required /></label></div><button type="submit" disabled={!templates.length || creating}>{creating ? "Создание VPS..." : "Создать VPS"}</button></form></section>
|
|
||||||
{user.role === "admin" && <section className="card"><h2>Создать шаблон</h2><form onSubmit={submitTemplate}><label>Имя<input value={templateForm.name} onChange={(e) => setTemplateForm({ ...templateForm, name: e.target.value })} required /></label><label>Описание<input value={templateForm.description} onChange={(e) => setTemplateForm({ ...templateForm, description: e.target.value })} /></label><label>Тип<select value={templateForm.guest_type} onChange={(e) => setTemplateForm({ ...templateForm, guest_type: e.target.value })}><option value="vm">VM</option><option value="lxc">LXC</option></select></label>{templateForm.guest_type === "vm" ? <label>Source VMID<input type="number" min="1" value={templateForm.source_vmid} onChange={(e) => setTemplateForm({ ...templateForm, source_vmid: e.target.value })} required /></label> : <label>CT template volume<input placeholder="local:vztmpl/debian.tar.zst" value={templateForm.source_template} onChange={(e) => setTemplateForm({ ...templateForm, source_template: e.target.value })} required /></label>}<div className="form-row"><label>CPU<input type="number" min="1" max="64" value={templateForm.cores} onChange={(e) => setTemplateForm({ ...templateForm, cores: e.target.value })} /></label><label>RAM MB<input type="number" min="128" value={templateForm.memory_mb} onChange={(e) => setTemplateForm({ ...templateForm, memory_mb: e.target.value })} /></label><label>Диск GB<input type="number" min="1" value={templateForm.disk_gb} onChange={(e) => setTemplateForm({ ...templateForm, disk_gb: e.target.value })} /></label></div><button type="submit">Создать шаблон</button></form></section>}
|
|
||||||
</main>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById("root")).render(<React.StrictMode><App /></React.StrictMode>);
|
createRoot(document.getElementById("root")).render(<React.StrictMode><App /></React.StrictMode>);
|
||||||
|
|||||||
Reference in New Issue
Block a user