mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-05-19 14:36:05 +00:00
v2.5.0: add local web admin dashboard
This commit is contained in:
300
admin-web/static/app.js
Normal file
300
admin-web/static/app.js
Normal file
@@ -0,0 +1,300 @@
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const state = { overview: null, users: [], events: [] };
|
||||
|
||||
const fmtBytes = (value = 0) => {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let n = Number(value) || 0;
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
const fmtDate = (epoch) => new Date(epoch * 1000).toLocaleString();
|
||||
|
||||
const toast = (message) => {
|
||||
const el = $("#toast");
|
||||
el.textContent = message;
|
||||
el.classList.add("show");
|
||||
clearTimeout(toast._timer);
|
||||
toast._timer = setTimeout(() => el.classList.remove("show"), 2600);
|
||||
};
|
||||
|
||||
const event = (title, detail = "") => {
|
||||
state.events.unshift({ title, detail, time: new Date() });
|
||||
state.events = state.events.slice(0, 8);
|
||||
renderEvents();
|
||||
};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = {
|
||||
"Accept": "application/json",
|
||||
"X-GoTelegram-Admin": "1",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
||||
const res = await fetch(path, { ...options, headers, credentials: "same-origin" });
|
||||
if (res.status === 401) {
|
||||
$("#authLock").classList.remove("hidden");
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data.data ?? data;
|
||||
}
|
||||
|
||||
function renderServices(services = {}) {
|
||||
const items = [
|
||||
{ key: "telemt", label: "telemt", api: "telemt" },
|
||||
{ key: "nginx", label: "nginx", api: "nginx" },
|
||||
{ key: "bot", label: "bot", api: "gotelegram-bot" },
|
||||
{ key: "stats", label: "stats", api: "gotelegram-stats" },
|
||||
{ key: "admin", label: "admin", api: "gotelegram-admin" },
|
||||
];
|
||||
$("#services").innerHTML = items.map((item) => {
|
||||
const status = services[item.key] || "unknown";
|
||||
return `<article class="service ${status}">
|
||||
<strong>${item.label}</strong>
|
||||
<div class="status"><span class="dot"></span><span>${status}</span></div>
|
||||
<button class="soft" data-restart="${item.api}" ${item.key === "admin" ? "disabled" : ""}>Restart</button>
|
||||
</article>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const data = state.overview;
|
||||
if (!data) return;
|
||||
const cfg = data.config || {};
|
||||
const stats = data.stats_current || {};
|
||||
$("#metricMode").textContent = cfg.mode || "--";
|
||||
$("#metricDomain").textContent = cfg.domain || cfg.mask_host || "--";
|
||||
$("#metricUsers").textContent = data.users_count ?? 0;
|
||||
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes);
|
||||
$("#metricProxyPackets").textContent = `${stats.proxy_pkts || 0} packets`;
|
||||
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
|
||||
$("#metricSitePackets").textContent = `${stats.site_pkts || 0} packets`;
|
||||
$("#runtimeBox").textContent = JSON.stringify(data.runtime_summary || {}, null, 2);
|
||||
renderServices(data.services || {});
|
||||
renderBackups(data.backups || []);
|
||||
drawTrafficChart(data.stats_history || []);
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const tbody = $("#usersTable");
|
||||
if (!state.users.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="4">No keys yet</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.users.map((user) => `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(user.name)}</strong>${user.main ? " <small>main</small>" : ""}</td>
|
||||
<td><code title="${escapeHtml(user.secret)}">${escapeHtml(user.secret)}</code></td>
|
||||
<td><button class="soft" data-copy="${escapeAttr(user.link)}">Copy link</button></td>
|
||||
<td class="actions">
|
||||
<button class="soft" data-copy="${escapeAttr(user.secret)}">Copy secret</button>
|
||||
<button class="danger" data-delete="${escapeAttr(user.name)}" ${user.main ? "disabled" : ""}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderBackups(backups) {
|
||||
const box = $("#backupsList");
|
||||
if (!backups.length) {
|
||||
box.innerHTML = `<div class="backup-item"><strong>No backups</strong><span></span></div>`;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = backups.map((item) => `
|
||||
<div class="backup-item">
|
||||
<div>
|
||||
<strong>${escapeHtml(item.name)}</strong>
|
||||
<span>${escapeHtml(item.path)} · ${fmtDate(item.mtime)}</span>
|
||||
</div>
|
||||
<div>${fmtBytes(item.size)}${item.encrypted ? " · encrypted" : ""}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
$("#events").innerHTML = state.events.map((item) => `
|
||||
<div class="event">
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
<small>${escapeHtml(item.detail || item.time.toLocaleTimeString())}</small>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function drawTrafficChart(rows) {
|
||||
const canvas = $("#trafficChart");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.max(320, rect.width) * ratio;
|
||||
canvas.height = 260 * ratio;
|
||||
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
const w = canvas.width / ratio;
|
||||
const h = canvas.height / ratio;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const pad = { l: 48, r: 18, t: 20, b: 34 };
|
||||
const points = rows.length ? rows : [{ proxy_delta: 0, site_delta: 0 }, { proxy_delta: 0, site_delta: 0 }];
|
||||
const max = Math.max(1, ...points.map((p) => Math.max(p.proxy_delta || 0, p.site_delta || 0)));
|
||||
const plotW = w - pad.l - pad.r;
|
||||
const plotH = h - pad.t - pad.b;
|
||||
|
||||
ctx.strokeStyle = "#dfe6f1";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const y = pad.t + (plotH / 4) * i;
|
||||
ctx.moveTo(pad.l, y);
|
||||
ctx.lineTo(w - pad.r, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const line = (key, color) => {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.beginPath();
|
||||
points.forEach((p, i) => {
|
||||
const x = pad.l + (plotW * i) / Math.max(1, points.length - 1);
|
||||
const y = pad.t + plotH - ((p[key] || 0) / max) * plotH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
};
|
||||
line("proxy_delta", "#2563eb");
|
||||
line("site_delta", "#0f9f6e");
|
||||
|
||||
ctx.fillStyle = "#647087";
|
||||
ctx.font = "12px system-ui";
|
||||
ctx.fillText(`max ${fmtBytes(max)}/min`, pad.l, 14);
|
||||
ctx.fillStyle = "#2563eb";
|
||||
ctx.fillText("proxy", pad.l, h - 10);
|
||||
ctx.fillStyle = "#0f9f6e";
|
||||
ctx.fillText("site", pad.l + 58, h - 10);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const btn = $("#refreshBtn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
state.overview = await api("/api/overview");
|
||||
state.users = await api("/api/users");
|
||||
renderOverview();
|
||||
renderUsers();
|
||||
} catch (err) {
|
||||
if (err.message !== "Unauthorized") toast(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addUser(name) {
|
||||
const data = await api("/api/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
event("Key created", data.name);
|
||||
toast("Key created");
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function deleteUser(name) {
|
||||
await api(`/api/users/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
event("Key deleted", name);
|
||||
toast("Key deleted");
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
const btn = $("#createBackupBtn");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const data = await api("/api/backups", { method: "POST", body: "{}" });
|
||||
event("Backup created", data.path || "");
|
||||
toast("Backup created");
|
||||
await refreshAll();
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const service = $("#logService").value;
|
||||
$("#logsBox").textContent = "Loading...";
|
||||
try {
|
||||
$("#logsBox").textContent = await api(`/api/logs?service=${encodeURIComponent(service)}`);
|
||||
} catch (err) {
|
||||
$("#logsBox").textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function restartService(name) {
|
||||
await api(`/api/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: "{}" });
|
||||
event("Service restarted", name);
|
||||
toast(`${name} restarted`);
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
})[ch]);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replace(/`/g, "`");
|
||||
}
|
||||
|
||||
document.addEventListener("click", async (eventObj) => {
|
||||
const target = eventObj.target.closest("button");
|
||||
if (!target) return;
|
||||
|
||||
if (target.dataset.copy) {
|
||||
await navigator.clipboard.writeText(target.dataset.copy);
|
||||
toast("Copied");
|
||||
}
|
||||
if (target.dataset.delete) {
|
||||
const name = target.dataset.delete;
|
||||
if (confirm(`Delete key ${name}?`)) deleteUser(name).catch((err) => toast(err.message));
|
||||
}
|
||||
if (target.dataset.restart) {
|
||||
const name = target.dataset.restart;
|
||||
if (confirm(`Restart ${name}?`)) restartService(name).catch((err) => toast(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
$("#addUserForm").addEventListener("submit", (eventObj) => {
|
||||
eventObj.preventDefault();
|
||||
const input = $("#userName");
|
||||
const name = input.value.trim();
|
||||
if (!/^[A-Za-z0-9_.-]{1,48}$/.test(name)) {
|
||||
toast("Use latin letters, digits, _, . or -");
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
addUser(name).catch((err) => toast(err.message));
|
||||
});
|
||||
|
||||
$("#refreshBtn").addEventListener("click", refreshAll);
|
||||
$("#createBackupBtn").addEventListener("click", createBackup);
|
||||
$("#loadLogsBtn").addEventListener("click", loadLogs);
|
||||
window.addEventListener("resize", () => state.overview && drawTrafficChart(state.overview.stats_history || []));
|
||||
|
||||
document.querySelectorAll("nav a").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
document.querySelectorAll("nav a").forEach((item) => item.classList.remove("active"));
|
||||
link.classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
refreshAll();
|
||||
loadLogs();
|
||||
158
admin-web/static/index.html
Normal file
158
admin-web/static/index.html
Normal file
@@ -0,0 +1,158 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoTelegram Admin</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="authLock" class="auth-lock hidden">
|
||||
<div class="auth-panel">
|
||||
<div class="mark">GT</div>
|
||||
<h1>GoTelegram Admin</h1>
|
||||
<p>Откройте ссылку из Telegram-бота после запуска SSH-туннеля.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">GT</div>
|
||||
<div>
|
||||
<strong>GoTelegram</strong>
|
||||
<span>Local Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="#dashboard" class="active">Dashboard</a>
|
||||
<a href="#keys">Keys</a>
|
||||
<a href="#traffic">Traffic</a>
|
||||
<a href="#backups">Backups</a>
|
||||
<a href="#logs">Logs</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">127.0.0.1:1984</p>
|
||||
<h1>GoTelegram Admin</h1>
|
||||
</div>
|
||||
<button id="refreshBtn" class="ghost">Refresh</button>
|
||||
</header>
|
||||
|
||||
<section id="dashboard" class="section">
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span>Mode</span>
|
||||
<strong id="metricMode">--</strong>
|
||||
<small id="metricDomain">--</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Keys</span>
|
||||
<strong id="metricUsers">0</strong>
|
||||
<small>configured users</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Proxy Traffic</span>
|
||||
<strong id="metricProxyTraffic">0 B</strong>
|
||||
<small id="metricProxyPackets">0 packets</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span>Site Traffic</span>
|
||||
<strong id="metricSiteTraffic">0 B</strong>
|
||||
<small id="metricSitePackets">0 packets</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="service-grid" id="services"></div>
|
||||
</section>
|
||||
|
||||
<section id="traffic" class="section split">
|
||||
<div>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Traffic</p>
|
||||
<h2>History</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="trafficChart" height="260"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="activity">
|
||||
<p class="eyebrow">Runtime</p>
|
||||
<pre id="runtimeBox">{}</pre>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section id="keys" class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Access</p>
|
||||
<h2>User keys</h2>
|
||||
</div>
|
||||
<form id="addUserForm" class="inline-form">
|
||||
<input id="userName" autocomplete="off" placeholder="client-name">
|
||||
<button type="submit">Add key</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Secret</th>
|
||||
<th>Link</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="backups" class="section split">
|
||||
<div>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Snapshots</p>
|
||||
<h2>Backups</h2>
|
||||
</div>
|
||||
<button id="createBackupBtn">Create backup</button>
|
||||
</div>
|
||||
<div id="backupsList" class="backup-list"></div>
|
||||
</div>
|
||||
<aside class="activity">
|
||||
<p class="eyebrow">Events</p>
|
||||
<div id="events"></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section id="logs" class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Journal</p>
|
||||
<h2>Logs</h2>
|
||||
</div>
|
||||
<div class="inline-form">
|
||||
<select id="logService">
|
||||
<option value="telemt">telemt</option>
|
||||
<option value="nginx">nginx</option>
|
||||
<option value="gotelegram-bot">bot</option>
|
||||
<option value="gotelegram-stats">stats</option>
|
||||
<option value="gotelegram-admin">admin</option>
|
||||
</select>
|
||||
<button id="loadLogsBtn" type="button">Load</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="logsBox" class="logs"></pre>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
410
admin-web/static/styles.css
Normal file
410
admin-web/static/styles.css
Normal file
@@ -0,0 +1,410 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-2: #f9fbff;
|
||||
--text: #172033;
|
||||
--muted: #647087;
|
||||
--line: #dfe6f1;
|
||||
--blue: #2563eb;
|
||||
--green: #0f9f6e;
|
||||
--amber: #c77700;
|
||||
--red: #d92d20;
|
||||
--ink: #0d172a;
|
||||
--shadow: 0 18px 55px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
button, input, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform .18s ease, box-shadow .18s ease, background .18s ease;
|
||||
}
|
||||
|
||||
button:hover { transform: translateY(-1px); box-shadow: 0 12px 25px rgba(16, 24, 40, .16); }
|
||||
button:disabled { opacity: .55; cursor: wait; transform: none; box-shadow: none; }
|
||||
button.ghost { background: #e8eef8; color: var(--ink); }
|
||||
button.danger { background: #fee4e2; color: #b42318; }
|
||||
button.soft { background: #eef4ff; color: #1d4ed8; }
|
||||
|
||||
input, select {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
padding: 0 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
border-color: #8bb4ff;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .12);
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 24px 18px;
|
||||
background: #0f172a;
|
||||
color: #e5edf8;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.brand-mark, .mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
background: #29b57f;
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
display: block;
|
||||
color: #93a4bc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #b8c5d8;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
transition: background .18s ease, color .18s ease;
|
||||
}
|
||||
|
||||
nav a:hover, nav a.active {
|
||||
background: rgba(255,255,255,.08);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1400px, 100%);
|
||||
padding: 26px;
|
||||
}
|
||||
|
||||
.topbar, .section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h1, h2, p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card, .chart-wrap, .table-wrap, .activity, .backup-list, .logs {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 124px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.metric-card span, .metric-card small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.service {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-2);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.running .dot { background: var(--green); }
|
||||
.failed .dot, .not_installed .dot { background: var(--red); }
|
||||
.inactive .dot, .stopped .dot { background: var(--amber); }
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.activity {
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#runtimeBox, .logs {
|
||||
max-height: 340px;
|
||||
overflow: auto;
|
||||
color: #344054;
|
||||
background: #f7f9fc;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
margin-top: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td code {
|
||||
display: inline-block;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
margin-top: 14px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.backup-item + .backup-item {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.backup-item span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#events {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.event small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
min-width: 240px;
|
||||
max-width: 420px;
|
||||
padding: 13px 14px;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
pointer-events: none;
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.auth-lock {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(245, 247, 251, .92);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 26px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-panel h1 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.auth-panel p {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
nav { display: flex; flex-wrap: wrap; }
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.service-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.split { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
main { padding: 18px; }
|
||||
.sidebar { padding: 18px; align-items: flex-start; flex-direction: column; }
|
||||
.metric-grid, .service-grid { grid-template-columns: 1fr; }
|
||||
.topbar, .section-head { align-items: flex-start; flex-direction: column; }
|
||||
.inline-form { width: 100%; flex-wrap: wrap; }
|
||||
.inline-form input, .inline-form select { flex: 1 1 180px; }
|
||||
.inline-form button { flex: 0 0 auto; }
|
||||
}
|
||||
Reference in New Issue
Block a user