Распаковал архив Proxmox-VPS-Panel.rar и добавил содержимое в репозиторий

This commit is contained in:
root
2026-07-24 15:10:47 +00:00
parent 0372e2c5f1
commit ac74a700ce
37 changed files with 2353 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
import React, { useEffect, useState, createContext, useContext } from "react";
import { Routes, Route, Navigate, Link, useNavigate } from "react-router-dom";
import { api } from "./api.js";
import Login from "./pages/Login.jsx";
import Register from "./pages/Register.jsx";
import Dashboard from "./pages/Dashboard.jsx";
import InstanceDetail from "./pages/InstanceDetail.jsx";
import AdminTemplates from "./pages/AdminTemplates.jsx";
import AdminUsers from "./pages/AdminUsers.jsx";
export const AuthContext = createContext(null);
export const useAuth = () => useContext(AuthContext);
function useBootstrap() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!localStorage.getItem("token")) {
setLoading(false);
return;
}
api
.me()
.then(setUser)
.catch(() => {
localStorage.removeItem("token");
})
.finally(() => setLoading(false));
}, []);
return { user, setUser, loading };
}
function Protected({ user, loading, children }) {
if (loading) return null;
if (!user) return <Navigate to="/login" replace />;
return children;
}
function Topbar() {
const { user, setUser } = useAuth();
const navigate = useNavigate();
return (
<div className="topbar">
<div className="brand">
<span className="prompt">$</span> vps-panel
</div>
{user && (
<div className="topbar-right">
<Link to="/">Мои VPS</Link>
{user.role === "admin" && <Link to="/admin/templates">Шаблоны</Link>}
{user.role === "admin" && <Link to="/admin/users">Пользователи</Link>}
<span className="badge">{user.email}</span>
<button
className="btn btn-sm"
onClick={() => {
api.logout();
setUser(null);
navigate("/login");
}}
>
Выйти
</button>
</div>
)}
</div>
);
}
export default function App() {
const { user, setUser, loading } = useBootstrap();
return (
<AuthContext.Provider value={{ user, setUser }}>
<div className="shell">
<Topbar />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/"
element={
<Protected user={user} loading={loading}>
<Dashboard />
</Protected>
}
/>
<Route
path="/instances/:id"
element={
<Protected user={user} loading={loading}>
<InstanceDetail />
</Protected>
}
/>
<Route
path="/admin/templates"
element={
<Protected user={user} loading={loading}>
<AdminTemplates />
</Protected>
}
/>
<Route
path="/admin/users"
element={
<Protected user={user} loading={loading}>
<AdminUsers />
</Protected>
}
/>
</Routes>
</div>
</AuthContext.Provider>
);
}
+75
View File
@@ -0,0 +1,75 @@
const BASE = "/api";
function getToken() {
return localStorage.getItem("token");
}
async function request(path, { method = "GET", body, auth = true } = {}) {
const headers = {};
let payload = body;
if (body instanceof URLSearchParams) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
} else if (body !== undefined) {
headers["Content-Type"] = "application/json";
payload = JSON.stringify(body);
}
if (auth) {
const token = getToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${BASE}${path}`, { method, headers, body: payload });
if (!res.ok) {
let detail = res.statusText;
try {
const data = await res.json();
detail = data.detail || detail;
} catch {
/* тело не JSON */
}
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
if (res.status === 204) return null;
return res.json();
}
export const api = {
register: (email, password) =>
request("/auth/register", { method: "POST", body: { email, password }, auth: false }),
login: async (email, password) => {
const form = new URLSearchParams();
form.set("username", email);
form.set("password", password);
const data = await request("/auth/login", { method: "POST", body: form, auth: false });
localStorage.setItem("token", data.access_token);
return data;
},
logout: () => localStorage.removeItem("token"),
me: () => request("/auth/me"),
listTemplates: () => request("/templates"),
createTemplate: (payload) => request("/templates", { method: "POST", body: payload }),
listInstances: () => request("/instances"),
createInstance: (payload) => request("/instances", { method: "POST", body: payload }),
getInstance: (id) => request(`/instances/${id}`),
getInstanceStatus: (id) => request(`/instances/${id}/status`),
instanceAction: (id, action) =>
request(`/instances/${id}/action`, { method: "POST", body: { action } }),
getInstanceLive: (id) => request(`/instances/${id}/live`),
getInstanceIp: (id) => request(`/instances/${id}/ip`),
deleteInstance: (id) => request(`/instances/${id}`, { method: "DELETE" }),
getConsole: (id) => request(`/instances/${id}/console`),
fetchProxmoxTemplates: () => request("/templates/from-proxmox"),
deleteTemplate: (id) => request(`/templates/${id}`, { method: "DELETE" }),
listUsers: () => request("/admin/users"),
toggleUser: (id) => request(`/admin/users/${id}/toggle-active`, { method: "POST" }),
};
+57
View File
@@ -0,0 +1,57 @@
import React, { useEffect, useRef, useState } from "react";
import { api } from "../api.js";
const NOVNC_CDN = "https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/lib/rfb.js";
export default function ConsoleViewer({ instanceId }) {
const containerRef = useRef(null);
const rfbRef = useRef(null);
const [status, setStatus] = useState("Подключение…");
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
async function connect() {
try {
const info = await api.getConsole(instanceId);
const { default: RFB } = await import(/* @vite-ignore */ NOVNC_CDN);
if (cancelled) return;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const wsUrl =
`${proto}://${window.location.host}/api/console/ws?` +
`node=${encodeURIComponent(info.node)}&vmid=${info.vmid}&guest_type=${info.guest_type}` +
`&port=${info.port}&ticket=${encodeURIComponent(info.ticket)}`;
const rfb = new RFB(containerRef.current, wsUrl);
rfb.addEventListener("connect", () => !cancelled && setStatus("Подключено"));
rfb.addEventListener("disconnect", () => !cancelled && setStatus("Соединение закрыто"));
rfbRef.current = rfb;
} catch (err) {
if (!cancelled) setError(err.message || String(err));
}
}
connect();
return () => {
cancelled = true;
rfbRef.current?.disconnect?.();
};
}, [instanceId]);
return (
<div>
<div className="status-row" style={{ marginBottom: 8 }}>
<span className={`dot ${error ? "error" : "running"}`} /> {error ? "Ошибка консоли" : status}
</div>
{error && (
<div className="error-box">
{error}. Консоль VNC зависит от версии Proxmox и настроек аутентификации см. README, раздел
«Консоль VNC».
</div>
)}
<div className="console-frame" ref={containerRef} style={{ width: "100%", height: 480 }} />
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api.js";
const STATUS_LABEL = {
creating: "создаётся", running: "работает", stopped: "остановлен",
error: "ошибка", deleting: "удаляется", deleted: "удалён",
};
function formatUptime(sec) {
if (!sec || sec <= 0) return "—";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d > 0) return `${d}д ${h}ч`;
if (h > 0) return `${h}ч ${m}м`;
return `${m}м`;
}
function formatMem(mb) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} ГБ`;
return `${fmt1(mb)} МБ`;
}
function fmt1(v) {
const n = Number(v);
return Number.isFinite(n) ? n.toFixed(1) : v;
}
export default function InstanceCard({ instance, onAction, onDelete }) {
const [ip, setIp] = useState("...");
const [live, setLive] = useState(null);
useEffect(() => {
if (instance.status === "running") {
api.getInstanceIp(instance.id).then((r) => setIp(r.ip)).catch(() => setIp("—"));
api.getInstanceLive(instance.id).then((r) => setLive(r)).catch(() => setLive(null));
const interval = setInterval(() => {
api.getInstanceLive(instance.id).then((r) => setLive(r)).catch(() => {});
}, 5000);
return () => clearInterval(interval);
} else {
setIp("—");
setLive(null);
}
}, [instance.id, instance.status]);
const cpuBar = live && live.cpu > 0 ? (
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<span>🔥 {live.cpu}%</span>
<div style={{ width: 50, height: 8, background: "#333", borderRadius: 4, overflow: "hidden" }}>
<div style={{ width: `${Math.min(live.cpu, 100)}%`, height: "100%",
background: live.cpu > 80 ? "#f44336" : live.cpu > 50 ? "#ff9800" : "#4caf50",
borderRadius: 4, transition: "width 0.5s" }} />
</div>
</div>
) : null;
const memBar = live && live.mem_total > 0 ? (
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<span>🧠 {fmt1(formatMem(live.mem_used / (1024 * 1024)))} / {fmt1(formatMem(live.mem_total / (1024 * 1024)))}</span>
<div style={{ width: 50, height: 8, background: "#333", borderRadius: 4, overflow: "hidden" }}>
<div style={{ width: `${fmt1(Math.min((live.mem_used / live.mem_total) * 100, 100))}%`, height: "100%",
background: (live.mem_used / live.mem_total) > 0.8 ? "#f44336" : (live.mem_used / live.mem_total) > 0.5 ? "#ff9800" : "#4caf50",
borderRadius: 4, transition: "width 0.5s" }} />
</div>
</div>
) : null;
return (
<div className="instance-card">
<div className="status-row">
<span className={`dot ${instance.status}`} />
{STATUS_LABEL[instance.status] || instance.status}
<span className="badge">{instance.guest_type === "vm" ? "VM" : "LXC"}</span>
{live && live.status === "running" && (
<span style={{ marginLeft: 8, fontSize: 12, color: "#aaa" }}>
{formatUptime(live.uptime)}
</span>
)}
</div>
<div className="instance-name">
<Link to={`/instances/${instance.id}`}>{instance.name}</Link>
</div>
{/* Live-показатели */}
{live && live.status === "running" && (
<div style={{ margin: "8px 0", display: "flex", gap: 16, flexWrap: "wrap", fontSize: 13 }}>
{cpuBar}
{memBar}
</div>
)}
{/* Статическая информация */}
<div className="instance-meta">
vmid {instance.vmid} · node {instance.node}
{instance.template && (
<> · 💻 {instance.template.cores} vCPU · 🧠 {instance.template.memory_mb} МБ · 💾 {instance.template.disk_gb} ГБ</>
)}
</div>
<div className="instance-meta" style={{ marginTop: 6 }}>
🌐 <strong>{ip}</strong>
{instance.ciuser && <> · 👤 {instance.ciuser}</>}
{instance.root_password && (
<span style={{ color: "#69f0ae" }}> · 🔑 {instance.root_password}</span>
)}
</div>
<div className="instance-actions">
<button className="btn btn-sm" disabled={instance.status !== "stopped"} onClick={() => onAction(instance.id, "start")}>Старт</button>
<button className="btn btn-sm" disabled={instance.status !== "running"} onClick={() => onAction(instance.id, "reboot")}>Reboot</button>
<button className="btn btn-sm" disabled={instance.status !== "running"} onClick={() => onAction(instance.id, "stop")}>Стоп</button>
<button className="btn btn-sm btn-danger" onClick={() => onDelete(instance.id)}>Удалить</button>
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+74
View File
@@ -0,0 +1,74 @@
import React, { useEffect, useState } from "react";
import { api } from "../api.js";
const empty = { name: "", description: "", guest_type: "vm", source_vmid: "", source_template: "", cores: 1, memory_mb: 1024, disk_gb: 10 };
export default function AdminTemplates() {
const [templates, setTemplates] = useState([]);
const [proxmoxTemplates, setProxmoxTemplates] = useState(null);
const [form, setForm] = useState(empty);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function refresh() { setTemplates(await api.listTemplates()); }
async function fetchProxmox() { setBusy(true); try { setProxmoxTemplates(await api.fetchProxmoxTemplates()); } catch (err) { setError(err.message); } finally { setBusy(false); } }
async function importVm(vm) { await api.createTemplate({ name: vm.name || "VM-"+vm.vmid, guest_type: "vm", source_vmid: vm.vmid, cores: vm.cores, memory_mb: vm.memory_mb, disk_gb: vm.disk_gb || 10 }); await refresh(); }
async function importLxc(lxc) { await api.createTemplate({ name: lxc.name, guest_type: "lxc", source_template: lxc.volid, cores: 1, memory_mb: 1024, disk_gb: 8 }); await refresh(); }
async function deleteTemplate(id) { if (!confirm("Удалить шаблон?")) return; await api.deleteTemplate(id); await refresh(); }
useEffect(() => { refresh(); }, []);
function set(f, v) { setForm(fr => ({ ...fr, [f]: v })); }
async function onSubmit(e) { e.preventDefault(); setError(""); setBusy(true); try { await api.createTemplate({ ...form, cores: Number(form.cores), memory_mb: Number(form.memory_mb), disk_gb: Number(form.disk_gb), source_vmid: form.source_vmid ? Number(form.source_vmid) : null, source_template: form.source_template || null }); setForm(empty); await refresh(); } catch (err) { setError(err.message); } finally { setBusy(false); } }
return (
<div className="container">
<h1 className="page-title">Шаблоны VPS</h1>
{error && <div className="error-box">{error}</div>}
<div className="card" style={{ marginBottom: 24 }}>
<h3>🔍 Найти шаблоны на Proxmox</h3>
<button className="btn btn-primary" onClick={fetchProxmox} disabled={busy}>{busy ? "Ищем…" : "Найти шаблоны"}</button>
{proxmoxTemplates && (
<div style={{ marginTop: 16 }}>
{proxmoxTemplates.vm_templates?.length > 0 && (
<div style={{ marginBottom: 16 }}><h4>🖥 VM-шаблоны</h4>
<table className="data-table"><thead><tr><th>VMID</th><th>Имя</th><th>vCPU</th><th>RAM</th><th></th></tr></thead>
<tbody>{proxmoxTemplates.vm_templates.map(vm => (
<tr key={vm.vmid}><td>{vm.vmid}</td><td>{vm.name}</td><td>{vm.cores}</td><td>{vm.memory_mb} МБ</td>
<td><button className="btn btn-primary" onClick={() => importVm(vm)}>Импорт</button></td></tr>))}
</tbody></table></div>)}
{proxmoxTemplates.lxc_templates?.length > 0 && (
<div><h4>📦 LXC-шаблоны</h4>
<table className="data-table"><thead><tr><th>Имя</th><th>Хранилище</th><th>Размер</th><th></th></tr></thead>
<tbody>{proxmoxTemplates.lxc_templates.map((lxc,i) => (
<tr key={i}><td>{lxc.name}</td><td>{lxc.storage}</td><td>{lxc.size_mb} МБ</td>
<td><button className="btn btn-primary" onClick={() => importLxc(lxc)}>Импорт</button></td></tr>))}
</tbody></table></div>)}
{(!proxmoxTemplates.vm_templates?.length && !proxmoxTemplates.lxc_templates?.length) && <p>Шаблоны не найдены.</p>}
</div>)}
</div>
<form onSubmit={onSubmit} className="card" style={{ marginBottom: 32 }}>
<h3> Добавить вручную</h3>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div className="field"><label>Название</label><input value={form.name} onChange={e => set("name", e.target.value)} required /></div>
<div className="field"><label>Тип</label><select value={form.guest_type} onChange={e => set("guest_type", e.target.value)}><option value="vm">VM</option><option value="lxc">LXC</option></select></div>
{form.guest_type === "vm" ? <div className="field"><label>VMID шаблона</label><input type="number" value={form.source_vmid} onChange={e => set("source_vmid", e.target.value)} placeholder="999" required /></div>
: <div className="field"><label>CT-шаблон (volid)</label><input value={form.source_template} onChange={e => set("source_template", e.target.value)} placeholder="local:vztmpl/..." required /></div>}
<div className="field"><label>Описание</label><input value={form.description} onChange={e => set("description", e.target.value)} /></div>
<div className="field"><label>vCPU</label><input type="number" min={1} value={form.cores} onChange={e => set("cores", e.target.value)} /></div>
<div className="field"><label>RAM, МБ</label><input type="number" min={256} step={256} value={form.memory_mb} onChange={e => set("memory_mb", e.target.value)} /></div>
<div className="field"><label>Диск, ГБ</label><input type="number" min={1} value={form.disk_gb} onChange={e => set("disk_gb", e.target.value)} /></div>
</div>
<button className="btn btn-primary" disabled={busy} type="submit">Добавить шаблон</button>
</form>
<h3>📋 Активные шаблоны</h3>
<table className="data-table"><thead><tr><th>Название</th><th>Тип</th><th>vCPU</th><th>RAM</th><th>Диск</th><th></th></tr></thead>
<tbody>{templates.map(t => (
<tr key={t.id}><td>{t.name}</td><td>{t.guest_type==="vm"?"VM":"LXC"}</td><td>{t.cores}</td><td>{t.memory_mb} МБ</td><td>{t.disk_gb} ГБ</td>
<td><button className="btn btn-danger" onClick={() => deleteTemplate(t.id)}>Удалить</button></td></tr>))}
</tbody></table>
</div>);
}
+50
View File
@@ -0,0 +1,50 @@
import React, { useEffect, useState } from "react";
import { api } from "../api.js";
export default function AdminUsers() {
const [users, setUsers] = useState([]);
async function refresh() {
setUsers(await api.listUsers());
}
useEffect(() => {
refresh();
}, []);
async function toggle(id) {
await api.toggleUser(id);
await refresh();
}
return (
<div className="container">
<h1 className="page-title">Пользователи</h1>
<p className="page-sub">Управление доступом клиентов к панели</p>
<table className="data-table">
<thead>
<tr>
<th>Email</th>
<th>Роль</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.email}</td>
<td>{u.role === "admin" ? "Администратор" : "Клиент"}</td>
<td>{u.is_active ? "Активен" : "Заблокирован"}</td>
<td>
<button className="btn btn-sm" onClick={() => toggle(u.id)}>
{u.is_active ? "Заблокировать" : "Разблокировать"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api.js";
import InstanceCard from "../components/InstanceCard.jsx";
const WHITE = "#fff", LIGHT = "#bbb", GREEN = "#69f0ae", DIM = "#777", BG = "#1e1e1e", BG2 = "#2a2a2a", BORDER = "#444";
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 = [
{ key: "small", label: "S", cpu: 1, ram: 1024, disk: 10 },
{ key: "medium", label: "M", cpu: 2, ram: 2048, disk: 20 },
{ key: "large", label: "L", cpu: 4, ram: 8192, disk: 50 },
{ key: "xlarge", label: "XL", cpu: 8, ram: 16384, disk: 100 },
];
function filterByOS(tpl, os) { if (!os) return tpl; return tpl.filter(t => t.name.toLowerCase().includes(os)); }
function matchPreset(tpl, p) {
const exact = tpl.find(t => t.cores===p.cpu && t.memory_mb===p.ram && t.disk_gb===p.disk);
if (exact) return exact;
return tpl.filter(t => t.cores>=p.cpu && t.memory_mb>=p.ram && t.disk_gb>=p.disk)
.sort((a,b)=>(a.cores+a.memory_mb/1024+a.disk_gb)-(b.cores+b.memory_mb/1024+b.disk_gb))[0]||null;
}
export default function Dashboard() {
const [instances, setInstances] = useState([]);
const [templates, setTemplates] = useState([]);
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState("");
const [ciuser, setCiuser] = useState("ubuntu");
const [cipassword, setCipassword] = useState("");
const [templateId, setTemplateId] = useState("");
const [selectedOS, setSelectedOS] = useState("ubuntu");
const [selectedPreset, setSelectedPreset] = useState(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const [inst, tpl] = await Promise.all([api.listInstances(), api.listTemplates()]);
setInstances(inst); setTemplates(tpl);
}, []);
useEffect(() => { refresh().finally(() => setLoading(false)); const i = setInterval(refresh, 8000); return () => clearInterval(i); }, [refresh]);
const osT = filterByOS(templates, selectedOS);
useEffect(() => {
if (selectedPreset) { const p = PRESETS.find(x => x.key===selectedPreset); const m = matchPreset(osT, p); if (m) setTemplateId(String(m.id)); }
else if (osT.length > 0) setTemplateId(String(osT[0].id));
}, [selectedOS, selectedPreset, templates]);
async function onCreate(e) {
e.preventDefault(); setError(""); setBusy(true);
try { await api.createInstance({ name, template_id: Number(templateId), ciuser: ciuser||"", cipassword: cipassword||"" });
setName(""); setCipassword(""); setShowForm(false); setSelectedPreset(null); await refresh(); }
catch (err) { setError(err.message); } finally { setBusy(false); }
}
async function onAction(id, action) { try { await api.instanceAction(id, action); await refresh(); } catch (err) { setError(err.message); } }
async function onDelete(id) { if (!confirm("Удалить VPS?")) return; try { await api.deleteInstance(id); await refresh(); } catch (err) { setError(err.message); } }
const running = instances.filter(i => i.status==="running").length;
const total = instances.length;
return (
<div style={{ maxWidth: 960, margin: "0 auto", padding: "24px 16px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24, flexWrap: "wrap", gap: 12 }}>
<div><h1 style={{ color: WHITE, fontSize: 24, margin: 0 }}>Мои VPS</h1>
<p style={{ color: LIGHT, fontSize: 13, margin: "4px 0 0" }}>{total===0?"Нет активных серверов":`${running} из ${total} запущено`}</p></div>
<button onClick={() => setShowForm(!showForm)}
style={{ padding: "10px 20px", borderRadius: 10, border: "none", cursor: "pointer", background: showForm?"#c62828":"#4caf50", color:"#fff", fontSize:14, fontWeight:600 }}>
{showForm?"✕ Отмена":"+ Новый VPS"}</button>
</div>
{showForm && (
<form onSubmit={onCreate} style={{ background: BG, borderRadius: 16, padding: 24, marginBottom: 24, border: `1px solid ${BORDER}` }}>
{error && <div className="error-box" style={{ marginBottom: 16 }}>{error}</div>}
<label style={{ color: LIGHT, fontSize: 12, textTransform: "uppercase", letterSpacing: 1, marginBottom: 10, display: "block" }}>Система</label>
<div style={{ display: "flex", gap: 10, marginBottom: 20, flexWrap: "wrap" }}>
{OS_OPTIONS.map(os => { const active=selectedOS===os.key; const has=filterByOS(templates,os.key).length>0; return (
<button key={os.key} type="button" disabled={!has} onClick={()=>{setSelectedOS(os.key);setSelectedPreset(null);}}
style={{ display:"flex", alignItems:"center", gap:8, padding:"8px 14px", borderRadius:8, border:active?`2px solid ${GREEN}`:`1px solid ${BORDER}`, background:active?"#1b5e20":has?BG2:"#111", cursor:has?"pointer":"not-allowed", opacity:has?1:0.4 }}>
<os.logo /><span style={{ color:WHITE, fontWeight:600, fontSize:13 }}>{os.label}</span><span style={{ color:DIM, fontSize:11 }}>{os.desc}</span></button>);})}
</div>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr 1fr", gap: 12, marginBottom: 16 }}>
<div><label style={{ color: LIGHT, fontSize: 11, display: "block", marginBottom: 4 }}>Имя сервера</label>
<input value={name} onChange={e=>setName(e.target.value)} placeholder="my-server" required style={{ width:"100%", padding:"9px 12px", borderRadius:8, border:`1px solid ${BORDER}`, background:BG2, color:WHITE, fontSize:14, boxSizing:"border-box" }}/></div>
<div><label style={{ color: LIGHT, fontSize: 11, display: "block", marginBottom: 4 }}>👤 Пользователь</label>
<input value={ciuser} onChange={e=>setCiuser(e.target.value)} placeholder="ubuntu" style={{ width:"100%", padding:"9px 12px", borderRadius:8, border:`1px solid ${BORDER}`, background:BG2, color:WHITE, fontSize:14, boxSizing:"border-box" }}/></div>
<div><label style={{ color: LIGHT, fontSize: 11, display: "block", marginBottom: 4 }}>🔑 Пароль</label>
<input type="password" value={cipassword} onChange={e=>setCipassword(e.target.value)} placeholder="••••••" style={{ width:"100%", padding:"9px 12px", borderRadius:8, border:`1px solid ${BORDER}`, background:BG2, color:WHITE, fontSize:14, boxSizing:"border-box" }}/></div>
</div>
<label style={{ color: LIGHT, fontSize: 11, display: "block", marginBottom: 6 }}>📦 Тариф</label>
<div style={{ display: "flex", gap: 10, marginBottom: 16, alignItems: "stretch", flexWrap: "wrap" }}>
<select value={templateId} onChange={e=>setTemplateId(e.target.value)} style={{ flex:1, padding:"9px 12px", borderRadius:8, border:`1px solid ${BORDER}`, background:BG2, color:WHITE, fontSize:13, minWidth:200 }}>
{osT.map(t=>(<option key={t.id} value={t.id}>{t.name} · {t.cores} vCPU · {t.memory_mb} MB · {t.disk_gb} GB</option>))}</select>
{PRESETS.map(p=>{const match=matchPreset(osT,p); const avail=!!match; const active=selectedPreset===p.key; return(
<button key={p.key} type="button" disabled={!avail} onClick={()=>{setSelectedPreset(p.key);const m=matchPreset(osT,p);if(m)setTemplateId(String(m.id));}}
style={{ padding:"8px 14px", borderRadius:8, border:active?`2px solid ${GREEN}`:`1px solid ${BORDER}`, background:active?"#1b5e20":avail?BG2:"#111", cursor:avail?"pointer":"not-allowed", opacity:avail?1:0.35, textAlign:"center" }}>
<div style={{ fontWeight:700, color:WHITE, fontSize:16 }}>{p.label}</div><div style={{ fontSize:10, color:avail?GREEN:DIM }}>{avail?"✅":"—"}</div></button>);})}
</div>
<button type="submit" disabled={busy||!templateId||!name} style={{ width:"100%", padding:"12px", borderRadius:10, border:"none", background:busy?"#555":"#4caf50", color:"#fff", fontSize:15, fontWeight:700, cursor:busy?"wait":"pointer" }}>
{busy?"⏳ Создаём...":"🚀 Развернуть VPS"}</button>
</form>)}
{loading ? <div style={{ textAlign:"center", padding:40, color:DIM }}>Загрузка...</div>
: instances.length===0 ? <div style={{ textAlign:"center", padding:60, color:DIM }}><div style={{ fontSize:56, marginBottom:16 }}></div><div style={{ fontSize:16, color:LIGHT }}>Нет активных VPS</div><div style={{ fontSize:13, marginTop:4 }}>Нажмите «+ Новый VPS» чтобы создать</div></div>
: <div style={{ display:"grid", gap:14 }}>{instances.map(inst=>(<InstanceCard key={inst.id} instance={inst} onAction={onAction} onDelete={onDelete} />))}</div>}
</div>);
}
+94
View File
@@ -0,0 +1,94 @@
import React, { useEffect, useState, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { api } from "../api.js";
import ConsoleViewer from "../components/ConsoleViewer.jsx";
export default function InstanceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [instance, setInstance] = useState(null);
const [showConsole, setShowConsole] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
const data = await api.getInstance(id);
setInstance(data);
}, [id]);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 5000);
return () => clearInterval(interval);
}, [refresh]);
async function doAction(action) {
setBusy(true);
setError("");
try {
await api.instanceAction(id, action);
await refresh();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function doDelete() {
if (!confirm("Удалить этот VPS безвозвратно?")) return;
setBusy(true);
try {
await api.deleteInstance(id);
navigate("/");
} catch (err) {
setError(err.message);
setBusy(false);
}
}
if (!instance) return <div className="container">Загрузка</div>;
return (
<div className="container">
<h1 className="page-title">{instance.name}</h1>
<p className="page-sub">
vmid {instance.vmid} · node {instance.node} · {instance.guest_type === "vm" ? "VM (QEMU)" : "LXC"}
</p>
{error && <div className="error-box">{error}</div>}
<div className="card" style={{ marginBottom: 24 }}>
<div className="status-row" style={{ marginBottom: 16 }}>
<span className={`dot ${instance.status}`} /> {instance.status}
</div>
<div className="instance-actions">
<button className="btn" disabled={busy || instance.status !== "stopped"} onClick={() => doAction("start")}>
Старт
</button>
<button className="btn" disabled={busy || instance.status !== "running"} onClick={() => doAction("reboot")}>
Перезагрузить
</button>
<button className="btn" disabled={busy || instance.status !== "running"} onClick={() => doAction("shutdown")}>
Выключить
</button>
<button className="btn" disabled={busy || instance.status !== "running"} onClick={() => doAction("stop")}>
Стоп (force)
</button>
<button className="btn" onClick={() => setShowConsole((v) => !v)}>
{showConsole ? "Скрыть консоль" : "Открыть консоль"}
</button>
<button className="btn btn-danger" disabled={busy} onClick={doDelete}>
Удалить
</button>
</div>
</div>
{showConsole && (
<div className="card">
<ConsoleViewer instanceId={instance.id} />
</div>
)}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const { setUser } = useAuth();
const navigate = useNavigate();
async function onSubmit(e) {
e.preventDefault();
setError("");
setBusy(true);
try {
await api.login(email, password);
const me = await api.me();
setUser(me);
navigate("/");
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<div className="auth-wrap">
<h1 className="page-title">Вход</h1>
<p className="page-sub">Личный кабинет управления VPS</p>
{error && <div className="error-box">{error}</div>}
<form onSubmit={onSubmit} className="card">
<div className="field">
<label>Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
</div>
<div className="field">
<label>Пароль</label>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
required
/>
</div>
<button className="btn btn-primary" type="submit" disabled={busy} style={{ width: "100%" }}>
{busy ? "Входим…" : "Войти"}
</button>
</form>
<p className="page-sub" style={{ marginTop: 16 }}>
Нет аккаунта? <Link to="/register">Зарегистрироваться</Link>
</p>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api } from "../api.js";
export default function Register() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [done, setDone] = useState(false);
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
async function onSubmit(e) {
e.preventDefault();
setError("");
setBusy(true);
try {
await api.register(email, password);
setDone(true);
setTimeout(() => navigate("/login"), 1200);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<div className="auth-wrap">
<h1 className="page-title">Регистрация</h1>
<p className="page-sub">Первый зарегистрированный пользователь получает права администратора</p>
{error && <div className="error-box">{error}</div>}
{done ? (
<div className="card">Готово, переходим на страницу входа</div>
) : (
<form onSubmit={onSubmit} className="card">
<div className="field">
<label>Email</label>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
</div>
<div className="field">
<label>Пароль</label>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
minLength={6}
required
/>
</div>
<button className="btn btn-primary" type="submit" disabled={busy} style={{ width: "100%" }}>
{busy ? "Создаём…" : "Создать аккаунт"}
</button>
</form>
)}
<p className="page-sub" style={{ marginTop: 16 }}>
Уже есть аккаунт? <Link to="/login">Войти</Link>
</p>
</div>
);
}
+346
View File
@@ -0,0 +1,346 @@
:root {
--bg: #0b1120;
--surface: #131b2e;
--surface-2: #1a2438;
--border: #26324a;
--text: #e6edf5;
--text-muted: #8b96a8;
--accent: #22d3aa;
--accent-dim: #16826a;
--warn: #f5a623;
--danger: #ef4444;
--font-mono: "IBM Plex Mono", ui-monospace, monospace;
--font-body: "Inter", system-ui, sans-serif;
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
}
button, input, select {
font-family: inherit;
}
a { color: var(--accent); text-decoration: none; }
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.brand {
font-family: var(--font-mono);
font-weight: 600;
letter-spacing: 0.02em;
font-size: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.brand .prompt { color: var(--accent); }
.topbar-right {
display: flex;
align-items: center;
gap: 16px;
font-size: 14px;
color: var(--text-muted);
}
.container {
max-width: 1040px;
margin: 0 auto;
padding: 32px 24px 80px;
width: 100%;
}
.page-title {
font-family: var(--font-mono);
font-size: 22px;
margin: 0 0 6px;
}
.page-sub {
color: var(--text-muted);
margin: 0 0 28px;
font-size: 14px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
cursor: pointer;
font-size: 14px;
transition: border-color 0.15s ease, background 0.15s ease;
}
.btn:hover { border-color: var(--accent-dim); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #06231c;
font-weight: 600;
}
.btn-primary:hover { background: #2ee6bb; }
.btn-danger {
border-color: #5c2626;
color: #ff8a8a;
}
.btn-danger:hover { border-color: var(--danger); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
}
.field label {
font-size: 13px;
color: var(--text-muted);
}
.field input, .field select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
color: var(--text);
font-size: 14px;
}
.field input:focus, .field select:focus {
outline: none;
border-color: var(--accent-dim);
}
.error-box {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #ff9d9d;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 13px;
margin-bottom: 16px;
}
.auth-wrap {
max-width: 380px;
margin: 90px auto;
}
.instance-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.instance-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.dot.running { background: var(--accent); box-shadow: 0 0 0 3px rgba(34, 211, 170, 0.2); }
.dot.stopped { background: var(--text-muted); }
.dot.creating { background: var(--warn); animation: pulse 1.4s infinite; }
.dot.error { background: var(--danger); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.instance-name {
font-size: 16px;
font-weight: 600;
}
.instance-meta {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}
.instance-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.nav-tabs {
display: flex;
gap: 4px;
margin-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.nav-tab {
padding: 10px 16px;
font-size: 14px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.nav-tab.active {
color: var(--text);
border-color: var(--accent);
}
table.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
table.data-table th, table.data-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
table.data-table th {
color: var(--text-muted);
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.console-frame {
background: #000;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.badge {
font-family: var(--font-mono);
font-size: 11px;
padding: 2px 8px;
border-radius: 100px;
border: 1px solid var(--border);
color: var(--text-muted);
}
/* === Компактный размер карточек VPS === */
.instance-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 340px));
gap: 16px;
justify-content: start;
}
.instance-card {
max-width: 340px;
padding: 14px 16px;
}
.instance-name {
font-size: 16px;
}
.instance-meta {
font-size: 12px;
}
.instance-actions .btn {
padding: 5px 10px;
font-size: 12px;
}
/* === Карточки VPS на всю ширину === */
.instance-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.instance-card {
max-width: none;
width: 100%;
}
/* === Сетка карточек: несколько в ряд === */
.instance-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
}
.instance-card {
max-width: none;
width: auto;
}
/* === Более светлый фон страницы === */
body {
background: #2b3448 !important;
}
.container,
.page-title,
.page-sub {
color: #f0f2f7;
}
.instance-card,
.card {
background: #39435c !important;
border: 1px solid #4d5878 !important;
}
.instance-meta {
color: #c3cadb;
}