Обновление файла
This commit is contained in:
@@ -1,12 +1,35 @@
|
|||||||
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 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 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 = () => (
|
||||||
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>);
|
<svg viewBox="0 0 64 64" width="28" height="28">
|
||||||
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>);
|
<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 = [
|
const OS_OPTIONS = [
|
||||||
{ key: "ubuntu", label: "Ubuntu", desc: "Универсальный", logo: UbuntuLogo },
|
{ key: "ubuntu", label: "Ubuntu", desc: "Универсальный", logo: UbuntuLogo },
|
||||||
@@ -20,12 +43,29 @@ const PRESETS = [
|
|||||||
{ key: "xlarge", label: "XL", cpu: 8, ram: 16384, disk: 100 },
|
{ 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 filterByOS(tpl, os) {
|
||||||
|
if (!os) return tpl;
|
||||||
|
return tpl.filter(t => t.name.toLowerCase().includes(os));
|
||||||
|
}
|
||||||
function matchPreset(tpl, p) {
|
function matchPreset(tpl, p) {
|
||||||
const exact = tpl.find(t => t.cores===p.cpu && t.memory_mb===p.ram && t.disk_gb===p.disk);
|
const exact = tpl.find(t => t.cores === p.cpu && t.memory_mb === p.ram && t.disk_gb === p.disk);
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
return tpl.filter(t => t.cores>=p.cpu && t.memory_mb>=p.ram && t.disk_gb>=p.disk)
|
return tpl
|
||||||
.sort((a,b)=>(a.cores+a.memory_mb/1024+a.disk_gb)-(b.cores+b.memory_mb/1024+b.disk_gb))[0]||null;
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Виджет-метрика на дашборде.
|
||||||
|
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() {
|
||||||
@@ -44,70 +84,284 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const [inst, tpl] = await Promise.all([api.listInstances(), api.listTemplates()]);
|
const [inst, tpl] = await Promise.all([api.listInstances(), api.listTemplates()]);
|
||||||
setInstances(inst); setTemplates(tpl);
|
setInstances(inst);
|
||||||
|
setTemplates(tpl);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { refresh().finally(() => setLoading(false)); const i = setInterval(refresh, 8000); return () => clearInterval(i); }, [refresh]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh().finally(() => setLoading(false));
|
||||||
|
const i = setInterval(refresh, 8000);
|
||||||
|
return () => clearInterval(i);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
const osT = filterByOS(templates, selectedOS);
|
const osT = filterByOS(templates, selectedOS);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPreset) { const p = PRESETS.find(x => x.key===selectedPreset); const m = matchPreset(osT, p); if (m) setTemplateId(String(m.id)); }
|
if (selectedPreset) {
|
||||||
else if (osT.length > 0) setTemplateId(String(osT[0].id));
|
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]);
|
}, [selectedOS, selectedPreset, templates]);
|
||||||
|
|
||||||
async function onCreate(e) {
|
async function onCreate(e) {
|
||||||
e.preventDefault(); setError(""); setBusy(true);
|
e.preventDefault();
|
||||||
try { await api.createInstance({ name, template_id: Number(templateId), ciuser: ciuser||"", cipassword: cipassword||"" });
|
setError("");
|
||||||
setName(""); setCipassword(""); setShowForm(false); setSelectedPreset(null); await refresh(); }
|
setBusy(true);
|
||||||
catch (err) { setError(err.message); } finally { setBusy(false); }
|
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;
|
async function onAction(id, action) {
|
||||||
const total = instances.length;
|
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 stats = {
|
||||||
|
total: instances.length,
|
||||||
|
running: instances.filter(i => i.status === "running").length,
|
||||||
|
stopped: instances.filter(i => i.status === "stopped").length,
|
||||||
|
error: instances.filter(i => i.status === "error").length,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 960, margin: "0 auto", padding: "24px 16px" }}>
|
<div className="container">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 24, flexWrap: "wrap", gap: 12 }}>
|
<div className="dashboard-header">
|
||||||
<div><h1 style={{ color: WHITE, fontSize: 24, margin: 0 }}>Мои VPS</h1>
|
<div>
|
||||||
<p style={{ color: LIGHT, fontSize: 13, margin: "4px 0 0" }}>{total===0?"Нет активных серверов":`${running} из ${total} запущено`}</p></div>
|
<h1 className="page-title">Мои VPS</h1>
|
||||||
<button onClick={() => setShowForm(!showForm)}
|
<p className="page-sub">
|
||||||
style={{ padding: "10px 20px", borderRadius: 10, border: "none", cursor: "pointer", background: showForm?"#c62828":"#4caf50", color:"#fff", fontSize:14, fontWeight:600 }}>
|
{stats.total === 0
|
||||||
{showForm?"✕ Отмена":"+ Новый VPS"}</button>
|
? "Нет активных серверов"
|
||||||
|
: `${stats.running} из ${stats.total} запущено`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
className={showForm ? "btn" : "btn btn-primary"}
|
||||||
|
onClick={() => setShowForm(s => !s)}
|
||||||
|
>
|
||||||
|
{showForm ? "✕ Отмена" : "+ Новый VPS"}
|
||||||
|
</button>
|
||||||
|
</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 && (
|
{showForm && (
|
||||||
<form onSubmit={onCreate} style={{ background: BG, borderRadius: 16, padding: 24, marginBottom: 24, border: `1px solid ${BORDER}` }}>
|
<form onSubmit={onCreate} className="card create-form">
|
||||||
{error && <div className="error-box" style={{ marginBottom: 16 }}>{error}</div>}
|
{error && <div className="error-box">{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>
|
<div className="form-section">
|
||||||
: 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>
|
<label className="form-label">Система</label>
|
||||||
: <div style={{ display:"grid", gap:14 }}>{instances.map(inst=>(<InstanceCard key={inst.id} instance={inst} onAction={onAction} onDelete={onDelete} />))}</div>}
|
<div className="os-grid">
|
||||||
</div>);
|
{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);
|
||||||
|
}}
|
||||||
|
className={"os-card" + (active ? " active" : "") + (!has ? " 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={e => setName(e.target.value)}
|
||||||
|
placeholder="my-server"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Пользователь</label>
|
||||||
|
<input
|
||||||
|
value={ciuser}
|
||||||
|
onChange={e => setCiuser(e.target.value)}
|
||||||
|
placeholder="ubuntu"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={cipassword}
|
||||||
|
onChange={e => setCipassword(e.target.value)}
|
||||||
|
placeholder="••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<label className="form-label">Тариф</label>
|
||||||
|
<div className="preset-row">
|
||||||
|
<select
|
||||||
|
value={templateId}
|
||||||
|
onChange={e => setTemplateId(e.target.value)}
|
||||||
|
className="preset-select"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
<div className="preset-buttons">
|
||||||
|
{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));
|
||||||
|
}}
|
||||||
|
className={"preset-btn" + (active ? " active" : "") + (!avail ? " disabled" : "")}
|
||||||
|
>
|
||||||
|
<div className="preset-label">{p.label}</div>
|
||||||
|
<div className="preset-status">{avail ? "✓" : "—"}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !templateId || !name}
|
||||||
|
className="btn btn-primary submit-btn"
|
||||||
|
>
|
||||||
|
{busy ? "⏳ Создаём..." : "🚀 Развернуть VPS"}
|
||||||
|
</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">Нажмите «+ Новый VPS» чтобы создать</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="instance-grid">
|
||||||
|
{instances.map(inst => (
|
||||||
|
<InstanceCard key={inst.id} instance={inst} onAction={onAction} onDelete={onDelete} />
|
||||||
|
))}
|
||||||
|
</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