Обновление файла

This commit is contained in:
2026-08-09 22:31:25 +03:00
parent 901e78e0a1
commit 14babe2677
+112 -117
View File
@@ -45,7 +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 [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("");
@@ -68,7 +68,7 @@ export default function Dashboard() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [refresh]); }, [refresh]);
// Шаблоны строго по типу: при выбранном "vm" — только VM, при "lxc" — только LXC. // Шаблоны строго по выбранному типу.
const typeTemplates = useMemo( const typeTemplates = useMemo(
() => templates.filter((t) => t.guest_type === guestType), () => templates.filter((t) => t.guest_type === guestType),
[templates, guestType] [templates, guestType]
@@ -78,12 +78,10 @@ export default function Dashboard() {
[typeTemplates, selectedOS] [typeTemplates, selectedOS]
); );
// При смене типа — сбросить выбор ОС и тарифа. Иначе может остаться шаблон // При смене типа — сбросить выбор тарифа и подобрать доступную ОС.
// другого типа или несуществующая ОС.
useEffect(() => { useEffect(() => {
setTemplateId(""); setTemplateId("");
setSelectedPreset(null); setSelectedPreset(null);
// Если текущая выбранная ОС недоступна для нового типа — сбросить на дефолт.
if (!OS_OPTIONS.some((os) => os.key === selectedOS && filterByOS(typeTemplates, os.key).length > 0)) { if (!OS_OPTIONS.some((os) => os.key === selectedOS && filterByOS(typeTemplates, os.key).length > 0)) {
const firstAvailable = OS_OPTIONS.find((os) => filterByOS(typeTemplates, os.key).length > 0); const firstAvailable = OS_OPTIONS.find((os) => filterByOS(typeTemplates, os.key).length > 0);
setSelectedOS(firstAvailable?.key || "ubuntu"); setSelectedOS(firstAvailable?.key || "ubuntu");
@@ -106,6 +104,9 @@ export default function Dashboard() {
// Открыть форму для конкретного типа — VM или LXC. // Открыть форму для конкретного типа — VM или LXC.
const openForm = (type) => { const openForm = (type) => {
setGuestType(type); setGuestType(type);
setName("");
setCipassword("");
setSelectedPreset(null);
setShowForm(true); setShowForm(true);
}; };
@@ -121,17 +122,12 @@ export default function Dashboard() {
setBusy(true); setBusy(true);
try { try {
const isLxc = guestType === "lxc"; const isLxc = guestType === "lxc";
// Для LXC пользователь — root (зашит в бэкенде), пароль — ввод пользователя.
// Для VM — стандартная cloud-init пара.
await api.createInstance({ await api.createInstance({
name, name,
template_id: Number(templateId), template_id: Number(templateId),
ciuser: isLxc ? "" : (ciuser || ""), ciuser: isLxc ? "" : (ciuser || ""),
cipassword: cipassword || "", cipassword: cipassword || "",
}); });
setName("");
setCipassword("");
setSelectedPreset(null);
setShowForm(false); setShowForm(false);
await refresh(); await refresh();
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
@@ -157,11 +153,7 @@ export default function Dashboard() {
}; };
const isLxc = guestType === "lxc"; const isLxc = guestType === "lxc";
const submitLabel = isLxc
? (busy ? "⏳ Создаём..." : "📦 Создать LXC")
: (busy ? "⏳ Создаём..." : "🚀 Развернуть VM");
const hasTypeTemplates = typeTemplates.length > 0; const hasTypeTemplates = typeTemplates.length > 0;
const typeLabel = isLxc ? "LXC-контейнеров" : "VM (виртуальных машин)";
return ( return (
<div className="container"> <div className="container">
@@ -185,123 +177,126 @@ export default function Dashboard() {
{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>} {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>}
{showForm && <form onSubmit={onCreate} className="card create-form"> {showForm && (
{error && <div className="error-box">{error}</div>} <form onSubmit={onCreate} className="card create-form">
{error && <div className="error-box">{error}</div>}
{/* Переключатель типа внутри формы — на случай если передумал. */} {/* Статический заголовок типа — переключатель убран, тип задаётся
<div className="form-section"> только кнопкой в шапке при открытии формы. */}
<label className="form-label">Тип сервера</label> <div className="form-type-banner">
<div className="type-tabs"> {isLxc ? (
<button <>
type="button" <span className="form-type-banner-icon">📦</span>
className={`type-tab ${!isLxc ? "active" : ""}`} <span className="form-type-banner-text">
onClick={() => setGuestType("vm")} Создаём <strong>LXC-контейнер</strong> · пользователь <code>root</code> · пароль задаёте сами
> </span>
<span className="type-tab-icon">🖥</span> </>
<span className="type-tab-label">VM</span> ) : (
<span className="type-tab-desc">cloud-init · любой пользователь</span> <>
</button> <span className="form-type-banner-icon">🖥</span>
<button <span className="form-type-banner-text">
type="button" Создаём <strong>виртуальную машину</strong> · cloud-init · любой пользователь
className={`type-tab ${isLxc ? "active" : ""}`} </span>
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>
<div className="form-row"> <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" style={{ flex: 2 }}><label>Имя сервера</label><input value={name} onChange={(event) => setName(event.target.value)} placeholder="my-server" required /></div>
{isLxc ? ( {isLxc ? (
<div className="field"><label>Пользователь</label><input value="root" disabled readOnly title="В LXC-контейнере всегда root" /></div> <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> <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="••••••" required={isLxc} title={isLxc ? "Задайте root-пароль для LXC" : "Пароль для cloud-init"} /></div> <div className="field"><label>Пароль</label><input type="password" value={cipassword} onChange={(event) => setCipassword(event.target.value)} placeholder="••••••" required={isLxc} title={isLxc ? "Задайте root-пароль для LXC" : "Пароль для cloud-init"} /></div>
</div> </div>
{/* Система и тариф — только если есть шаблоны этого типа. */} {/* Система и тариф — только если есть шаблоны этого типа. */}
{!hasTypeTemplates ? ( {!hasTypeTemplates ? (
<div className="form-section"> <div className="form-section">
<div className="empty-box"> <div className="empty-box">
<div className="empty-box-icon">📦</div> <div className="empty-box-icon">📦</div>
<div className="empty-box-title">Нет шаблонов для {typeLabel}</div> <div className="empty-box-title">Нет шаблонов для {isLxc ? "LXC-контейнеров" : "VM (виртуальных машин)"}</div>
<div className="empty-box-sub"> <div className="empty-box-sub">
Перейдите в раздел <strong>«Шаблоны»</strong> слева Перейдите в раздел <strong>«Шаблоны»</strong> слева
нажмите <strong>«🔍 Найти шаблоны на Proxmox»</strong> нажмите <strong>«🔍 Найти шаблоны на Proxmox»</strong>
импортируйте нужный шаблон. После этого он появится здесь. импортируйте нужный шаблон. После этого он появится здесь.
</div>
</div> </div>
</div> </div>
</div> ) : (
) : ( <>
<> <div className="form-section">
<div className="form-section"> <label className="form-label">Система</label>
<label className="form-label">Система</label> <div className="os-grid">{OS_OPTIONS.map((os) => {
<div className="os-grid">{OS_OPTIONS.map((os) => { const active = selectedOS === os.key;
const active = selectedOS === os.key; const available = filterByOS(typeTemplates, os.key).length > 0;
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">{available ? os.desc : "нет шаблонов"}</div>
</div>
</button>
);
})}</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="">Нет шаблонов для «{OS_OPTIONS.find((o) => o.key === selectedOS)?.label}»</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 ( return (
<button <button
key={preset.key} key={os.key}
type="button" type="button"
disabled={!available} disabled={!available}
onClick={() => { setSelectedPreset(preset.key); if (match) setTemplateId(String(match.id)); }} onClick={() => { setSelectedOS(os.key); setSelectedPreset(null); }}
className={`preset-btn${selectedPreset === preset.key ? " active" : ""}${!available ? " disabled" : ""}`} className={`os-card${active ? " active" : ""}${!available ? " disabled" : ""}`}
> >
<div className="preset-label">{preset.label}</div> <os.logo />
<div className="preset-status">{available ? "✓" : "—"}</div> <div>
<div className="os-name">{os.label}</div>
<div className="os-desc">{available ? os.desc : "нет шаблонов"}</div>
</div>
</button> </button>
); );
})}</div> })}</div>
</div> </div>
</div>
</>
)}
<button type="submit" disabled={busy || !templateId || !name || (isLxc && !cipassword) || !hasTypeTemplates} className="btn btn-primary submit-btn">{submitLabel}</button> <div className="form-section">
</form>} <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="">Нет шаблонов для «{OS_OPTIONS.find((o) => o.key === selectedOS)?.label}»</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 || (isLxc && !cipassword) || !hasTypeTemplates}
className="btn btn-primary submit-btn"
>
{busy ? "⏳ Создаём..." : (isLxc ? "📦 Создать LXC" : "🚀 Развернуть VM")}
</button>
</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">Нажмите «+ Новый VM» или «+ Новый LXC» чтобы создать</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">Нажмите «+ Новый VM» или «+ Новый LXC» чтобы создать</div></div> : <div className="instance-grid">{instances.map((instance) => <InstanceCard key={instance.id} instance={instance} onAction={onAction} onDelete={onDelete} />)}</div>}
</div> </div>
@@ -309,6 +304,6 @@ export default function Dashboard() {
} }
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 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 IconPlay = () => <svg viewBox="0 0 24 24" width="22" height="22" fill="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 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>; 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>;