feat(dashboard): переключатель VM/LXC + для LXC всегда root + пароль генерируется
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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";
|
||||||
@@ -45,6 +45,7 @@ export default function Dashboard() {
|
|||||||
const [instances, setInstances] = useState([]);
|
const [instances, setInstances] = useState([]);
|
||||||
const [templates, setTemplates] = useState([]);
|
const [templates, setTemplates] = useState([]);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [guestType, setGuestType] = useState("vm"); // ← НОВОЕ: "vm" | "lxc"
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ciuser, setCiuser] = useState("ubuntu");
|
const [ciuser, setCiuser] = useState("ubuntu");
|
||||||
const [cipassword, setCipassword] = useState("");
|
const [cipassword, setCipassword] = useState("");
|
||||||
@@ -67,7 +68,24 @@ export default function Dashboard() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const osTemplates = filterByOS(templates, selectedOS);
|
// Фильтрация шаблонов: сначала по типу (vm/lxc), потом по ОС.
|
||||||
|
const typeTemplates = useMemo(
|
||||||
|
() => templates.filter((t) => t.guest_type === guestType),
|
||||||
|
[templates, guestType]
|
||||||
|
);
|
||||||
|
const osTemplates = useMemo(
|
||||||
|
() => filterByOS(typeTemplates, selectedOS),
|
||||||
|
[typeTemplates, selectedOS]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сбрасываем выбранный тариф при смене типа — иначе может быть выбран
|
||||||
|
// шаблон другого типа.
|
||||||
|
useEffect(() => {
|
||||||
|
setTemplateId("");
|
||||||
|
setSelectedPreset(null);
|
||||||
|
}, [guestType]);
|
||||||
|
|
||||||
|
// Авто-выбор первого подходящего шаблона при изменении фильтров.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPreset) {
|
if (selectedPreset) {
|
||||||
const preset = PRESETS.find((item) => item.key === selectedPreset);
|
const preset = PRESETS.find((item) => item.key === selectedPreset);
|
||||||
@@ -75,8 +93,10 @@ export default function Dashboard() {
|
|||||||
if (match) setTemplateId(String(match.id));
|
if (match) setTemplateId(String(match.id));
|
||||||
} else if (osTemplates.length) {
|
} else if (osTemplates.length) {
|
||||||
setTemplateId(String(osTemplates[0].id));
|
setTemplateId(String(osTemplates[0].id));
|
||||||
|
} else {
|
||||||
|
setTemplateId("");
|
||||||
}
|
}
|
||||||
}, [selectedOS, selectedPreset, templates]);
|
}, [selectedOS, selectedPreset, osTemplates]);
|
||||||
|
|
||||||
async function onCreate(event) {
|
async function onCreate(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -84,7 +104,14 @@ export default function Dashboard() {
|
|||||||
setError("");
|
setError("");
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.createInstance({ name, template_id: Number(templateId), ciuser: ciuser || "", cipassword: cipassword || "" });
|
// Для LXC ciuser/cipassword не используются — пароль генерируется на бэкенде.
|
||||||
|
// Передаём пустые строки, чтобы схема Pydantic не ругалась.
|
||||||
|
await api.createInstance({
|
||||||
|
name,
|
||||||
|
template_id: Number(templateId),
|
||||||
|
ciuser: guestType === "vm" ? (ciuser || "") : "",
|
||||||
|
cipassword: guestType === "vm" ? (cipassword || "") : "",
|
||||||
|
});
|
||||||
setName("");
|
setName("");
|
||||||
setCipassword("");
|
setCipassword("");
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
@@ -112,6 +139,11 @@ export default function Dashboard() {
|
|||||||
error: instances.filter((item) => item.status === "error").length,
|
error: instances.filter((item) => item.status === "error").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLxc = guestType === "lxc";
|
||||||
|
const submitLabel = isLxc
|
||||||
|
? (busy ? "⏳ Создаём..." : "📦 Создать LXC")
|
||||||
|
: (busy ? "⏳ Создаём..." : "🚀 Развернуть VM");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<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 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>
|
||||||
@@ -120,10 +152,51 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{showForm && <form onSubmit={onCreate} className="card create-form">
|
{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"><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>
|
|
||||||
<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>
|
{/* Тип: VM (cloud-init) или LXC (root, пароль генерируется автоматически). */}
|
||||||
<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>
|
<div className="form-section">
|
||||||
<button type="submit" disabled={busy || !templateId || !name} className="btn btn-primary submit-btn">{busy ? "⏳ Создаём..." : "🚀 Развернуть VPS"}</button>
|
<label className="form-label">Тип сервера</label>
|
||||||
|
<div className="type-tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`type-tab ${!isLxc ? "active" : ""}`}
|
||||||
|
onClick={() => setGuestType("vm")}
|
||||||
|
>
|
||||||
|
<span className="type-tab-icon">🖥</span>
|
||||||
|
<span className="type-tab-label">VM</span>
|
||||||
|
<span className="type-tab-desc">cloud-init · любой пользователь</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`type-tab ${isLxc ? "active" : ""}`}
|
||||||
|
onClick={() => setGuestType("lxc")}
|
||||||
|
>
|
||||||
|
<span className="type-tab-icon">📦</span>
|
||||||
|
<span className="type-tab-label">LXC</span>
|
||||||
|
<span className="type-tab-desc">root · пароль генерируется</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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(typeTemplates, 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{isLxc ? (
|
||||||
|
<div className="field"><label>Пользователь</label><input value="root" disabled readOnly title="В LXC-контейнере всегда root" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="field"><label>Пользователь</label><input value={ciuser} onChange={(event) => setCiuser(event.target.value)} placeholder="ubuntu" /></div>
|
||||||
|
)}
|
||||||
|
{isLxc ? (
|
||||||
|
<div className="field"><label>Пароль</label><input value="•• сгенерируется автоматически ••" disabled readOnly title="Пароль будет сгенерирован сервером и показан один раз после создания" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="field"><label>Пароль</label><input type="password" value={cipassword} onChange={(event) => setCipassword(event.target.value)} placeholder="••••••" /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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.length === 0 ? <option value="">Нет шаблонов — импортируйте в разделе «Шаблоны»</option> : 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>
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy || !templateId || !name} className="btn btn-primary submit-btn">{submitLabel}</button>
|
||||||
</form>}
|
</form>}
|
||||||
|
|
||||||
{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>}
|
{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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user