660 lines
25 KiB
HTML
660 lines
25 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Dev Dashboard</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
/* Material You Inspired Color Palette */
|
|
--md-sys-color-primary: #6750A4;
|
|
--md-sys-color-on-primary: #FFFFFF;
|
|
--md-sys-color-primary-container: #EADDFF;
|
|
--md-sys-color-on-primary-container: #21005D;
|
|
--md-sys-color-secondary-container: #E8DEF8;
|
|
--md-sys-color-on-secondary-container: #1D192B;
|
|
--md-sys-color-error: #B3261E;
|
|
--md-sys-color-error-container: #F9DEDC;
|
|
--md-sys-color-on-error-container: #410E0B;
|
|
--md-sys-color-background: #FEF7FF;
|
|
--md-sys-color-on-background: #1D1B20;
|
|
--md-sys-color-surface: #F3EDF7;
|
|
--md-sys-color-on-surface: #1D1B20;
|
|
--md-sys-color-outline: #79747E;
|
|
|
|
--border-radius-large: 24px;
|
|
--border-radius-medium: 16px;
|
|
--border-radius-small: 8px;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: 'Roboto', sans-serif;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--md-sys-color-background);
|
|
color: var(--md-sys-color-on-background);
|
|
display: flex;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Sidebar Navigation */
|
|
.sidebar {
|
|
width: 280px;
|
|
background-color: var(--md-sys-color-surface);
|
|
padding: 24px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
border-right: 1px solid var(--md-sys-color-outline);
|
|
box-shadow: 2px 0 8px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: 0 16px 24px 16px;
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
color: var(--md-sys-color-primary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
.nav-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 16px;
|
|
border-radius: 100px;
|
|
cursor: pointer;
|
|
color: var(--md-sys-color-on-surface);
|
|
transition: background-color 0.2s, color 0.2s;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.nav-item:hover {
|
|
background-color: var(--md-sys-color-secondary-container);
|
|
}
|
|
|
|
.nav-item.active {
|
|
background-color: var(--md-sys-color-primary-container);
|
|
color: var(--md-sys-color-on-primary-container);
|
|
}
|
|
|
|
/* Main Content Area */
|
|
.main-content {
|
|
flex: 1;
|
|
padding: 32px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.section {
|
|
display: none;
|
|
animation: fadeIn 0.3s ease-in-out;
|
|
}
|
|
|
|
.section.active {
|
|
display: block;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
h2 {
|
|
font-size: 2rem;
|
|
margin-bottom: 24px;
|
|
color: var(--md-sys-color-primary);
|
|
}
|
|
|
|
/* Material Cards */
|
|
.card-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 24px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.card {
|
|
background-color: var(--md-sys-color-surface);
|
|
border-radius: var(--border-radius-large);
|
|
padding: 24px;
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 500;
|
|
margin-bottom: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* Forms & Inputs */
|
|
.input-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
input, select, textarea {
|
|
padding: 16px;
|
|
border-radius: var(--border-radius-small);
|
|
border: 1px solid var(--md-sys-color-outline);
|
|
background: transparent;
|
|
font-size: 1rem;
|
|
color: var(--md-sys-color-on-surface);
|
|
outline: none;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
input:focus, select:focus, textarea:focus {
|
|
border-color: var(--md-sys-color-primary);
|
|
border-width: 2px;
|
|
padding: 15px; /* Offset border width jump */
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
padding: 12px 24px;
|
|
border-radius: 100px;
|
|
border: none;
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background-color: var(--md-sys-color-primary);
|
|
color: var(--md-sys-color-on-primary);
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
box-shadow: 0 2px 8px rgba(103, 80, 164, 0.4);
|
|
}
|
|
|
|
.btn-error {
|
|
background-color: var(--md-sys-color-error);
|
|
color: white;
|
|
}
|
|
|
|
/* Tables */
|
|
.table-container {
|
|
background: var(--md-sys-color-surface);
|
|
border-radius: var(--border-radius-large);
|
|
overflow: hidden;
|
|
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
text-align: left;
|
|
}
|
|
|
|
th, td {
|
|
padding: 16px;
|
|
border-bottom: 1px solid var(--md-sys-color-outline);
|
|
}
|
|
|
|
th {
|
|
background-color: var(--md-sys-color-secondary-container);
|
|
color: var(--md-sys-color-on-secondary-container);
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Badges */
|
|
.badge {
|
|
padding: 4px 8px;
|
|
border-radius: 8px;
|
|
font-size: 0.85rem;
|
|
font-weight: bold;
|
|
background: var(--md-sys-color-primary-container);
|
|
color: var(--md-sys-color-on-primary-container);
|
|
}
|
|
|
|
.badge.banned {
|
|
background: var(--md-sys-color-error-container);
|
|
color: var(--md-sys-color-on-error-container);
|
|
}
|
|
|
|
/* Toast Notifications */
|
|
#toast-container {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
right: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.toast {
|
|
background-color: #322F35;
|
|
color: #F5EFF7;
|
|
padding: 16px 24px;
|
|
border-radius: var(--border-radius-small);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
animation: slideIn 0.3s ease-out forwards;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<nav class="sidebar">
|
|
<div class="sidebar-header">
|
|
<span class="material-icons-round">terminal</span>
|
|
DevSpace
|
|
</div>
|
|
<div class="nav-item active" onclick="switchTab('dashboard')">
|
|
<span class="material-icons-round">dashboard</span> System Overview
|
|
</div>
|
|
<div class="nav-item" onclick="switchTab('users'); loadUsers();">
|
|
<span class="material-icons-round">people</span> User Management
|
|
</div>
|
|
<div class="nav-item" onclick="switchTab('content'); loadServers();">
|
|
<span class="material-icons-round">forum</span> Content Moderation
|
|
</div>
|
|
<div class="nav-item" onclick="switchTab('codes'); loadCodes();">
|
|
<span class="material-icons-round">vpn_key</span> Invite Codes
|
|
</div>
|
|
<div class="nav-item" onclick="switchTab('system')">
|
|
<span class="material-icons-round">settings_power</span> System Operations
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="main-content">
|
|
|
|
<section id="dashboard" class="section active">
|
|
<h2>System Overview</h2>
|
|
<div class="card-grid">
|
|
<div class="card">
|
|
<div class="card-title"><span class="material-icons-round">storage</span> Database Size</div>
|
|
<h1 id="stat-db-size">-- MB</h1>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title"><span class="material-icons-round">person</span> Active Connections</div>
|
|
<h1 id="stat-users">--</h1>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title"><span class="material-icons-round">message</span> Total Messages</div>
|
|
<h1 id="stat-messages">--</h1>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title"><span class="material-icons-round">schedule</span> Uptime</div>
|
|
<h1 id="stat-uptime" style="font-size: 1.5rem;">--</h1>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary" onclick="loadStats()">
|
|
<span class="material-icons-round">refresh</span> Refresh Stats
|
|
</button>
|
|
</section>
|
|
|
|
<section id="users" class="section">
|
|
<h2>User Management</h2>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Username</th>
|
|
<th>Role</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="users-table-body">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="content" class="section">
|
|
<h2>Content Moderation</h2>
|
|
<div class="card-grid">
|
|
<div class="card">
|
|
<div class="card-title">Select Server & Channel</div>
|
|
<div class="input-group">
|
|
<select id="server-select" onchange="loadChannels()"><option>Loading...</option></select>
|
|
</div>
|
|
<div class="input-group">
|
|
<select id="channel-select" onchange="loadMessages()"><option>Select Server First</option></select>
|
|
</div>
|
|
</div>
|
|
<div class="card" style="grid-column: span 2;">
|
|
<div class="card-title" style="justify-content: space-between;">
|
|
<span>Messages</span>
|
|
<button class="btn btn-error" onclick="bulkDelete()">Bulk Delete Selected</button>
|
|
</div>
|
|
<div class="table-container" style="max-height: 400px; overflow-y: auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th><input type="checkbox" id="select-all" onclick="toggleSelectAll()"></th>
|
|
<th>Author</th>
|
|
<th>Content</th>
|
|
<th>Date</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="messages-table-body">
|
|
<tr><td colspan="5">Select a channel to view messages</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="codes" class="section">
|
|
<h2>Invite Codes</h2>
|
|
<div class="card-grid">
|
|
<div class="card">
|
|
<div class="card-title">Generate Codes</div>
|
|
<div class="input-group">
|
|
<input type="number" id="code-count" placeholder="Number of codes (default 10)" min="1">
|
|
</div>
|
|
<button class="btn btn-primary" onclick="generateCodes(false)">Generate Random</button>
|
|
<hr style="margin: 24px 0; border: 0; border-top: 1px solid var(--md-sys-color-outline);">
|
|
<div class="input-group">
|
|
<input type="text" id="custom-code" placeholder="Custom Code (e.g. SUMMER2026)">
|
|
</div>
|
|
<button class="btn btn-primary" onclick="generateCodes(true)">Create Custom</button>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-title">Active Unused Codes</div>
|
|
<div class="table-container" style="max-height: 300px; overflow-y: auto;">
|
|
<table>
|
|
<tbody id="codes-table-body">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="system" class="section">
|
|
<h2>System Operations</h2>
|
|
<div class="card-grid">
|
|
<div class="card">
|
|
<div class="card-title"><span class="material-icons-round">campaign</span> Global Broadcast</div>
|
|
<p style="margin-bottom: 16px; font-size: 0.9rem;">Sends a database message to ALL channels and ALL users (DMs).</p>
|
|
<div class="input-group">
|
|
<textarea id="global-msg" rows="3" placeholder="Enter broadcast message..."></textarea>
|
|
</div>
|
|
<button class="btn btn-error" onclick="sendBroadcast('global')">Send Global Message</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title"><span class="material-icons-round">notifications_active</span> SSE Notification</div>
|
|
<p style="margin-bottom: 16px; font-size: 0.9rem;">Pushes a live popup notification to currently connected clients.</p>
|
|
<div class="input-group">
|
|
<textarea id="sse-msg" rows="3" placeholder="Enter notification message..."></textarea>
|
|
</div>
|
|
<button class="btn btn-primary" onclick="sendBroadcast('sse')">Send Live Alert</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title"><span class="material-icons-round">restart_alt</span> Server Management</div>
|
|
<p style="margin-bottom: 16px; font-size: 0.9rem;">Requires systemd and sudo permissions set up on the host.</p>
|
|
<button class="btn btn-error" onclick="restartServer()">Restart dnishe.service</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<div id="toast-container"></div>
|
|
|
|
<script>
|
|
// --- Utilities ---
|
|
const API_BASE = '/api';
|
|
|
|
function switchTab(tabId) {
|
|
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
|
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
document.getElementById(tabId).classList.add('active');
|
|
event.currentTarget.classList.add('active');
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast';
|
|
|
|
const icon = type === 'error' ? 'error' : (type === 'success' ? 'check_circle' : 'info');
|
|
toast.innerHTML = `<span class="material-icons-round">${icon}</span> ${message}`;
|
|
|
|
container.appendChild(toast);
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
async function apiCall(endpoint, method = 'GET', body = null) {
|
|
const options = {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include' // Important for auth_token cookie
|
|
};
|
|
if (body) options.body = JSON.stringify(body);
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}${endpoint}`, options);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'API Error');
|
|
return data;
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// --- Dashboard Stats ---
|
|
async function loadStats() {
|
|
try {
|
|
const stats = await apiCall('/dev/system-stats', 'POST'); // app.py expects POST
|
|
document.getElementById('stat-db-size').innerText = `${stats.db_size_mb} MB`;
|
|
document.getElementById('stat-users').innerText = stats.active_users;
|
|
document.getElementById('stat-messages').innerText = stats.message_count;
|
|
document.getElementById('stat-uptime').innerText = stats.uptime;
|
|
} catch (e) {}
|
|
}
|
|
|
|
// --- User Management ---
|
|
async function loadUsers() {
|
|
try {
|
|
const users = await apiCall('/admin/users', 'POST'); // app.py expects POST
|
|
const tbody = document.getElementById('users-table-body');
|
|
tbody.innerHTML = '';
|
|
users.forEach(u => {
|
|
const statusBadge = u.is_banned
|
|
? `<span class="badge banned">Banned</span>`
|
|
: `<span class="badge">Active</span>`;
|
|
const actionBtn = u.is_banned
|
|
? `<button class="btn btn-primary" onclick="toggleBan(${u.id}, 0)">Unban</button>`
|
|
: `<button class="btn btn-error" onclick="toggleBan(${u.id}, 1)">Ban</button>`;
|
|
|
|
tbody.innerHTML += `
|
|
<tr>
|
|
<td>${u.id}</td>
|
|
<td>${u.username}</td>
|
|
<td><span class="badge">${u.badge || 'User'}</span></td>
|
|
<td>${statusBadge}</td>
|
|
<td>${actionBtn}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function toggleBan(userId, banStatus) {
|
|
try {
|
|
await apiCall('/admin/ban', 'POST', { user_id: userId, ban: banStatus });
|
|
showToast(`User ${banStatus ? 'banned' : 'unbanned'} successfully`, 'success');
|
|
loadUsers();
|
|
} catch (e) {}
|
|
}
|
|
|
|
// --- Content Moderation ---
|
|
async function loadServers() {
|
|
try {
|
|
const servers = await apiCall('/admin/servers', 'POST'); // app.py expects POST
|
|
const select = document.getElementById('server-select');
|
|
select.innerHTML = '<option value="">Select a Server</option>';
|
|
servers.forEach(s => {
|
|
select.innerHTML += `<option value="${s.id}">${s.name}</option>`;
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function loadChannels() {
|
|
const serverId = document.getElementById('server-select').value;
|
|
if (!serverId) return;
|
|
try {
|
|
const channels = await apiCall('/admin/channels', 'POST', { server_id: serverId });
|
|
const select = document.getElementById('channel-select');
|
|
select.innerHTML = '<option value="">Select a Channel</option>';
|
|
channels.forEach(c => {
|
|
select.innerHTML += `<option value="${c.id}">${c.name}</option>`;
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function loadMessages() {
|
|
const channelId = document.getElementById('channel-select').value;
|
|
if (!channelId) return;
|
|
try {
|
|
const messages = await apiCall('/admin/messages', 'POST', { channel_id: channelId });
|
|
const tbody = document.getElementById('messages-table-body');
|
|
tbody.innerHTML = '';
|
|
if(messages.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5">No messages found.</td></tr>';
|
|
return;
|
|
}
|
|
messages.forEach(m => {
|
|
let content = m.content;
|
|
try { content = JSON.parse(m.content).text || "[File Attachment]"; } catch(e){}
|
|
|
|
tbody.innerHTML += `
|
|
<tr>
|
|
<td><input type="checkbox" class="msg-checkbox" value="${m.id}"></td>
|
|
<td>${m.username}</td>
|
|
<td>${content}</td>
|
|
<td>${new Date(m.timestamp).toLocaleString()}</td>
|
|
<td><button class="btn btn-error" style="padding: 8px 16px;" onclick="deleteMessage(${m.id})">Delete</button></td>
|
|
</tr>
|
|
`;
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function deleteMessage(msgId) {
|
|
if(!confirm("Delete this message?")) return;
|
|
try {
|
|
await apiCall(`/admin/message/${msgId}`, 'DELETE');
|
|
showToast("Message deleted", "success");
|
|
loadMessages();
|
|
} catch(e) {}
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const isChecked = document.getElementById('select-all').checked;
|
|
document.querySelectorAll('.msg-checkbox').forEach(cb => cb.checked = isChecked);
|
|
}
|
|
|
|
async function bulkDelete() {
|
|
const selected = Array.from(document.querySelectorAll('.msg-checkbox:checked')).map(cb => parseInt(cb.value));
|
|
if(selected.length === 0) return showToast("No messages selected", "error");
|
|
if(!confirm(`Delete ${selected.length} messages?`)) return;
|
|
|
|
try {
|
|
await apiCall('/admin/bulk-delete', 'POST', { message_ids: selected });
|
|
showToast(`Deleted ${selected.length} messages`, "success");
|
|
loadMessages();
|
|
} catch(e) {}
|
|
}
|
|
|
|
// --- Invite Codes ---
|
|
async function loadCodes() {
|
|
try {
|
|
const codes = await apiCall('/dev/list-codes', 'GET');
|
|
const tbody = document.getElementById('codes-table-body');
|
|
tbody.innerHTML = '';
|
|
codes.forEach(c => {
|
|
tbody.innerHTML += `<tr><td style="font-family: monospace; font-size: 1.1rem;">${c.code}</td></tr>`;
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function generateCodes(isCustom) {
|
|
const payload = {};
|
|
if (isCustom) {
|
|
payload.custom = document.getElementById('custom-code').value;
|
|
if(!payload.custom) return showToast('Enter a custom code', 'error');
|
|
} else {
|
|
payload.num = parseInt(document.getElementById('code-count').value) || 10;
|
|
}
|
|
|
|
try {
|
|
await apiCall('/dev/generate-code', 'POST', payload);
|
|
showToast('Codes generated successfully', 'success');
|
|
if(isCustom) document.getElementById('custom-code').value = '';
|
|
loadCodes();
|
|
} catch (e) {}
|
|
}
|
|
|
|
// --- System Operations ---
|
|
async function sendBroadcast(type) {
|
|
const isGlobal = type === 'global';
|
|
const endpoint = isGlobal ? '/dev/global-broadcast' : '/dev/broadcast';
|
|
const inputId = isGlobal ? 'global-msg' : 'sse-msg';
|
|
const message = document.getElementById(inputId).value;
|
|
|
|
if(!message) return showToast('Message cannot be empty', 'error');
|
|
if(isGlobal && !confirm("WARNING: This will insert a DB message into EVERY channel and DM. Proceed?")) return;
|
|
|
|
try {
|
|
await apiCall(endpoint, 'POST', { message });
|
|
showToast('Broadcast sent successfully', 'success');
|
|
document.getElementById(inputId).value = '';
|
|
} catch(e) {}
|
|
}
|
|
|
|
async function restartServer() {
|
|
if(!confirm("Are you sure you want to restart the background service? This drops all connections.")) return;
|
|
try {
|
|
const res = await apiCall('/dev/restart', 'POST');
|
|
showToast(res.status === 'restarting' ? 'Restart command issued' : 'Restarted', 'success');
|
|
} catch(e) {}
|
|
}
|
|
|
|
// Initialize on load
|
|
window.onload = () => {
|
|
loadStats();
|
|
};
|
|
</script>
|
|
</body>
|
|
</html> |