Dnishe/templates/dev.html
2026-06-08 21:56:14 +00:00

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>