feat: implement admin dashboard with server monitoring, user management, and room control utilities
This commit is contained in:
parent
4e56f7110d
commit
9ccfbd1dc0
5 changed files with 408 additions and 91 deletions
|
|
@ -230,3 +230,87 @@ def admin_nuke_room():
|
|||
db_remove_public_room(room)
|
||||
|
||||
return jsonify({"status": "nuked", "room": room})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/unban", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_unban():
|
||||
data = request.get_json()
|
||||
ban_type = data.get("type")
|
||||
value = data.get("value")
|
||||
|
||||
if not ban_type or not value:
|
||||
return jsonify({"error": "Missing type or value"}), 400
|
||||
|
||||
if ban_type == "ip":
|
||||
banned_ips.discard(value)
|
||||
from models.database import db_remove_ban
|
||||
db_remove_ban("ip", value)
|
||||
elif ban_type == "username":
|
||||
banned_usernames.discard(value)
|
||||
from models.database import db_remove_ban
|
||||
db_remove_ban("username", value)
|
||||
else:
|
||||
return jsonify({"error": "Invalid ban type"}), 400
|
||||
|
||||
return jsonify({"unbanned": value})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/room/setowner", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_set_room_owner():
|
||||
data = request.get_json()
|
||||
room = data.get("room")
|
||||
owner = data.get("owner") # If None, clears the owner
|
||||
|
||||
if not room:
|
||||
return jsonify({"error": "Missing room name"}), 400
|
||||
|
||||
if owner:
|
||||
room_owners[room] = owner
|
||||
from models.database import db_set_room_owner
|
||||
db_set_room_owner(room, owner)
|
||||
else:
|
||||
room_owners.pop(room, None)
|
||||
from models.database import db_delete_room_owner
|
||||
db_delete_room_owner(room)
|
||||
|
||||
return jsonify({"status": "updated", "room": room, "owner": owner})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/kick", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_kick_user():
|
||||
data = request.get_json()
|
||||
room = data.get("room")
|
||||
target = data.get("target")
|
||||
|
||||
if not room or not target:
|
||||
return jsonify({"error": "Missing room or target"}), 400
|
||||
|
||||
kicked_users.setdefault(room, set()).add(target)
|
||||
room_users.get(room, {}).pop(target, None)
|
||||
return jsonify({"kicked": target, "room": room})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/room/clearmessages", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_clear_room_messages():
|
||||
data = request.get_json()
|
||||
room = data.get("room")
|
||||
|
||||
if not room:
|
||||
return jsonify({"error": "Missing room name"}), 400
|
||||
|
||||
messages[room] = []
|
||||
return jsonify({"status": "cleared", "room": room})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/room/messages", methods=["GET"])
|
||||
@requires_auth
|
||||
def admin_room_messages():
|
||||
room = request.args.get("room")
|
||||
if not room:
|
||||
return jsonify({"error": "Missing room parameter"}), 400
|
||||
return jsonify(messages.get(room, []))
|
||||
|
||||
|
|
|
|||
13
config.py
13
config.py
|
|
@ -1,6 +1,19 @@
|
|||
import os
|
||||
import secrets
|
||||
|
||||
# Manually load .env variables if present
|
||||
_env_path = os.path.join(os.path.dirname(__file__), ".env")
|
||||
if os.path.exists(_env_path):
|
||||
try:
|
||||
with open(_env_path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, val = line.split("=", 1)
|
||||
os.environ[key.strip()] = val.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Config:
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
|
||||
|
|
|
|||
|
|
@ -1,76 +1,92 @@
|
|||
/* === Admin Panel === */
|
||||
#serverStats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 30px;
|
||||
background-color: #2a2a2a;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#serverStats > div {
|
||||
background-color: #3d3d3d;
|
||||
padding: 15px 20px;
|
||||
border-radius: 6px;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#roomList,
|
||||
#userList,
|
||||
#bannedList {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-left: 0; /* Override collapsible default */
|
||||
}
|
||||
|
||||
#roomList > div,
|
||||
#userList > div,
|
||||
#bannedList > div {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
padding: 12px 15px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Make buttons in lists smaller/neater */
|
||||
#roomList button,
|
||||
#userList button {
|
||||
min-height: 32px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
#broadcastInput {
|
||||
background-color: #ffffff;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
#bulkInput {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background-color: #ffffff;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* === Admin Panel === */
|
||||
#serverStats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 30px;
|
||||
background-color: #2a2a2a;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#serverStats > div {
|
||||
background-color: #3d3d3d;
|
||||
padding: 15px 20px;
|
||||
border-radius: 6px;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#roomList,
|
||||
#userList,
|
||||
#bannedList {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-left: 0; /* Override collapsible default */
|
||||
}
|
||||
|
||||
#roomList > div,
|
||||
#userList > div,
|
||||
#bannedList > div {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
padding: 12px 15px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Make buttons in lists smaller/neater */
|
||||
#roomList button,
|
||||
#userList button {
|
||||
min-height: 32px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
#broadcastInput {
|
||||
background-color: #ffffff;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
#bulkInput {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background-color: #ffffff;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Transcript Overlay Modal */
|
||||
#transcriptOverlay button {
|
||||
background-color: #d9534f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
#transcriptOverlay button:hover {
|
||||
background-color: #c9302c;
|
||||
}
|
||||
|
|
@ -57,35 +57,133 @@ async function loadRooms() {
|
|||
const div = document.getElementById('roomList');
|
||||
const countDisplay = document.getElementById('activeRoomCount');
|
||||
|
||||
const activeRooms = rooms.filter(r => r.users.length > 0); // Filter active only
|
||||
div.innerHTML = '';
|
||||
|
||||
div.innerHTML = ''; // Clear previous content
|
||||
|
||||
activeRooms.forEach(r => {
|
||||
rooms.forEach(r => {
|
||||
const container = document.createElement('div');
|
||||
container.style.border = '1px solid #444';
|
||||
container.style.padding = '12px 15px';
|
||||
container.style.marginBottom = '10px';
|
||||
container.style.borderRadius = '6px';
|
||||
container.style.background = '#222';
|
||||
container.style.display = 'block';
|
||||
|
||||
const titleRow = document.createElement('div');
|
||||
titleRow.style.display = 'flex';
|
||||
titleRow.style.justifyContent = 'space-between';
|
||||
titleRow.style.alignItems = 'center';
|
||||
titleRow.style.marginBottom = '8px';
|
||||
|
||||
const titleGroup = document.createElement('div');
|
||||
const link = document.createElement('a');
|
||||
link.href = r.type === 'watchparty'
|
||||
? `/watchparty_chat.html?room=${encodeURIComponent(r.name)}`
|
||||
: `/chat.html?room=${encodeURIComponent(r.name)}`;
|
||||
link.target = "_blank";
|
||||
link.textContent = r.name;
|
||||
link.style.fontWeight = 'bold';
|
||||
link.style.fontSize = '1.1em';
|
||||
|
||||
const details = document.createElement('span');
|
||||
details.style.fontSize = '0.9em';
|
||||
details.style.color = '#666';
|
||||
details.style.marginLeft = '10px';
|
||||
details.textContent = `Owner: ${r.owner} | Msgs: ${r.msg_count} | ${r.is_public ? 'Public' : 'Unlisted'} | Users: ${r.users.join(', ')}`;
|
||||
const typeSpan = document.createElement('span');
|
||||
typeSpan.textContent = ` (${r.type} | ${r.is_public ? 'Public' : 'Unlisted'})`;
|
||||
typeSpan.style.color = '#888';
|
||||
typeSpan.style.fontSize = '0.95em';
|
||||
typeSpan.style.marginLeft = '5px';
|
||||
|
||||
titleGroup.append(link, typeSpan);
|
||||
titleRow.appendChild(titleGroup);
|
||||
|
||||
const actionsDiv = document.createElement('div');
|
||||
|
||||
const viewBtn = document.createElement('button');
|
||||
viewBtn.textContent = 'View Transcript';
|
||||
viewBtn.style.marginLeft = '5px';
|
||||
viewBtn.onclick = () => viewTranscript(r.name);
|
||||
|
||||
const clearBtn = document.createElement('button');
|
||||
clearBtn.textContent = 'Clear History';
|
||||
clearBtn.style.marginLeft = '5px';
|
||||
clearBtn.onclick = () => clearHistory(r.name);
|
||||
|
||||
const nukeBtn = document.createElement('button');
|
||||
nukeBtn.textContent = 'Nuke';
|
||||
nukeBtn.style.marginLeft = '10px';
|
||||
nukeBtn.style.backgroundColor = '#d9534f'; // Red warning color
|
||||
nukeBtn.style.marginLeft = '5px';
|
||||
nukeBtn.style.backgroundColor = '#d9534f';
|
||||
nukeBtn.style.color = 'white';
|
||||
nukeBtn.onclick = () => nukeRoom(r.name);
|
||||
|
||||
container.append(link, details, nukeBtn);
|
||||
actionsDiv.append(viewBtn, clearBtn, nukeBtn);
|
||||
titleRow.appendChild(actionsDiv);
|
||||
container.appendChild(titleRow);
|
||||
|
||||
const metaRow = document.createElement('div');
|
||||
metaRow.style.fontSize = '0.9em';
|
||||
metaRow.style.color = '#aaa';
|
||||
metaRow.style.marginBottom = '8px';
|
||||
metaRow.style.display = 'flex';
|
||||
metaRow.style.alignItems = 'center';
|
||||
metaRow.style.gap = '10px';
|
||||
|
||||
const ownerLabel = document.createElement('span');
|
||||
ownerLabel.textContent = `Owner: ${r.owner}`;
|
||||
|
||||
const changeOwnerBtn = document.createElement('button');
|
||||
changeOwnerBtn.textContent = 'Assign Owner';
|
||||
changeOwnerBtn.style.padding = '2px 6px';
|
||||
changeOwnerBtn.style.fontSize = '11px';
|
||||
changeOwnerBtn.onclick = () => assignOwner(r.name);
|
||||
|
||||
const reclaimOwnerBtn = document.createElement('button');
|
||||
reclaimOwnerBtn.textContent = 'Reclaim Owner (Reset)';
|
||||
reclaimOwnerBtn.style.padding = '2px 6px';
|
||||
reclaimOwnerBtn.style.fontSize = '11px';
|
||||
reclaimOwnerBtn.onclick = () => reclaimOwner(r.name);
|
||||
|
||||
metaRow.append(ownerLabel, changeOwnerBtn, reclaimOwnerBtn);
|
||||
container.appendChild(metaRow);
|
||||
|
||||
const usersRow = document.createElement('div');
|
||||
usersRow.style.fontSize = '0.9em';
|
||||
usersRow.style.color = '#ccc';
|
||||
|
||||
if (r.users.length === 0) {
|
||||
usersRow.textContent = 'Users: (None)';
|
||||
} else {
|
||||
usersRow.textContent = 'Users: ';
|
||||
r.users.forEach(user => {
|
||||
const userSpan = document.createElement('span');
|
||||
userSpan.style.display = 'inline-flex';
|
||||
userSpan.style.alignItems = 'center';
|
||||
userSpan.style.marginRight = '8px';
|
||||
userSpan.style.marginBottom = '4px';
|
||||
userSpan.style.padding = '2px 6px';
|
||||
userSpan.style.border = '1px solid #555';
|
||||
userSpan.style.borderRadius = '3px';
|
||||
userSpan.style.background = '#111';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = user;
|
||||
|
||||
const kickBtn = document.createElement('button');
|
||||
kickBtn.textContent = 'Kick';
|
||||
kickBtn.style.marginLeft = '5px';
|
||||
kickBtn.style.padding = '1px 4px';
|
||||
kickBtn.style.fontSize = '10px';
|
||||
kickBtn.style.background = 'none';
|
||||
kickBtn.style.border = 'none';
|
||||
kickBtn.style.color = '#ff5722';
|
||||
kickBtn.style.cursor = 'pointer';
|
||||
kickBtn.style.minHeight = 'auto';
|
||||
kickBtn.onclick = () => adminKickUser(r.name, user);
|
||||
|
||||
userSpan.append(nameSpan, kickBtn);
|
||||
usersRow.appendChild(userSpan);
|
||||
});
|
||||
}
|
||||
container.appendChild(usersRow);
|
||||
div.appendChild(container);
|
||||
});
|
||||
countDisplay.textContent = `(${activeRooms.length})`;
|
||||
countDisplay.textContent = `(${rooms.length})`;
|
||||
}
|
||||
|
||||
async function banUser(username) {
|
||||
|
|
@ -123,7 +221,29 @@ async function loadBanned() {
|
|||
div.innerHTML = '';
|
||||
banned.forEach(item => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = `${item.type}: ${item.value}`;
|
||||
el.style.display = 'flex';
|
||||
el.style.alignItems = 'center';
|
||||
el.style.justifyContent = 'space-between';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `${item.type}: ${item.value}`;
|
||||
|
||||
const unbanBtn = document.createElement('button');
|
||||
unbanBtn.textContent = 'Unban';
|
||||
unbanBtn.style.marginLeft = '10px';
|
||||
unbanBtn.onclick = async () => {
|
||||
if (confirm(`Remove ban for ${item.value}?`)) {
|
||||
await fetch('/admin/unban', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: item.type, value: item.value })
|
||||
});
|
||||
loadBanned();
|
||||
loadStats();
|
||||
}
|
||||
};
|
||||
|
||||
el.append(label, unbanBtn);
|
||||
div.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
|
@ -256,4 +376,88 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function viewTranscript(room) {
|
||||
const res = await fetch(`/admin/room/messages?room=${encodeURIComponent(room)}`);
|
||||
const msgs = await res.json();
|
||||
|
||||
let overlay = document.getElementById('transcriptOverlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'transcriptOverlay';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '10%';
|
||||
overlay.style.left = '10%';
|
||||
overlay.style.width = '80%';
|
||||
overlay.style.height = '80%';
|
||||
overlay.style.background = '#1a1f2c';
|
||||
overlay.style.color = 'white';
|
||||
overlay.style.border = '2px solid #555';
|
||||
overlay.style.borderRadius = '8px';
|
||||
overlay.style.padding = '20px';
|
||||
overlay.style.zIndex = '10000';
|
||||
overlay.style.overflowY = 'auto';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
overlay.style.display = 'block';
|
||||
overlay.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #444; padding-bottom: 10px; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0;">Transcript: ${room}</h3>
|
||||
<button onclick="document.getElementById('transcriptOverlay').style.display='none'">Close</button>
|
||||
</div>
|
||||
<div id="transcriptContent" style="font-family: monospace; white-space: pre-wrap; line-height: 1.4; max-height: calc(100% - 50px); overflow-y: auto;"></div>
|
||||
`;
|
||||
const contentDiv = document.getElementById('transcriptContent');
|
||||
if (msgs.length === 0) {
|
||||
contentDiv.textContent = "(No messages in room)";
|
||||
} else {
|
||||
contentDiv.textContent = msgs.map(m => {
|
||||
const timeStr = new Date(m.timestamp).toLocaleTimeString();
|
||||
return `[${timeStr}] ${m.username}: ${m.text}`;
|
||||
}).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearHistory(room) {
|
||||
if (!confirm(`Clear all message history for room: "${room}"?`)) return;
|
||||
await fetch('/admin/room/clearmessages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room })
|
||||
});
|
||||
loadRooms();
|
||||
loadStats();
|
||||
}
|
||||
|
||||
async function assignOwner(room) {
|
||||
const owner = prompt(`Enter new owner username for room: "${room}":`);
|
||||
if (owner === null) return;
|
||||
const trimmed = owner.trim();
|
||||
await fetch('/admin/room/setowner', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room, owner: trimmed || null })
|
||||
});
|
||||
loadRooms();
|
||||
}
|
||||
|
||||
async function reclaimOwner(room) {
|
||||
if (!confirm(`Reclaim ownership for room: "${room}"? This will clear the owner, letting the next active user claim it.`)) return;
|
||||
await fetch('/admin/room/setowner', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room, owner: null })
|
||||
});
|
||||
loadRooms();
|
||||
}
|
||||
|
||||
async function adminKickUser(room, username) {
|
||||
if (!confirm(`Kick user "${username}" from room: "${room}"?`)) return;
|
||||
await fetch('/admin/kick', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room, target: username })
|
||||
});
|
||||
loadRooms();
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in a new issue