feat: add admin login page and authentication API
- Created a new login page for admin access at frontend/admin/login/index.html. - Implemented form submission with error handling for login credentials. - Updated frontend/index.html to mute the video player and removed unnecessary Alpine.js data binding. - Enhanced manifest URL resolution in frontend/src/main.ts to include cache-busting parameters. - Modified vite.config.ts to include the new admin login page in the build process. - Updated nginx.conf to handle admin login routing, authentication, and API proxying for admin actions.
This commit is contained in:
1 parent
1ae0ad121b
commit
6b494a34fe
15 files changed
+1896
-52
No files matched your search
+702
-32
@@ -10,7 +10,7 @@
|
||||
<link rel="stylesheet" href="../src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-950 text-slate-200">
|
||||
<body class="bg-slate-950 text-slate-200" data-admin-default-moderator="%VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR%" data-admin-default-room="%VITE_PUBLIC_ADMIN_DEFAULT_ROOM%" data-admin-moderation-log-limit="%VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT%">
|
||||
<div class="flex h-screen" x-data="admin()">
|
||||
<!-- Sidebar -->
|
||||
<div class="w-64 bg-slate-900 p-6">
|
||||
@@ -18,15 +18,18 @@
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold">LV</div>
|
||||
<div class="text-xl font-bold">Admin</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<button @click="logout()" class="w-full bg-slate-800 hover:bg-slate-700 border border-slate-700 px-4 py-2 rounded-xl text-sm text-left">Sign out</button>
|
||||
</div>
|
||||
<nav class="space-y-2">
|
||||
<a href="#" @click="currentTab = 'dashboard'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'dashboard' }">Dashboard</a>
|
||||
<a href="#" @click="currentTab = 'upload'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a>
|
||||
<a href="#" @click="currentTab = 'playlist'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a>
|
||||
<a href="#" @click="currentTab = 'schedule'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a>
|
||||
<a href="#" @click="currentTab = 'moderation'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a>
|
||||
<a href="#" @click="currentTab = 'users'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a>
|
||||
<a href="#" @click="currentTab = 'analytics'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a>
|
||||
<a href="#" @click="currentTab = 'system'" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
|
||||
<a href="#" @click="currentTab = 'upload'; refreshMediaLibrary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'upload' }">Upload Video</a>
|
||||
<a href="#" @click="currentTab = 'playlist'; refreshPlaylist()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'playlist' }">Playlist</a>
|
||||
<a href="#" @click="currentTab = 'schedule'; refreshSchedule()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'schedule' }">Schedule</a>
|
||||
<a href="#" @click="currentTab = 'moderation'; refreshModerationLogs()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'moderation' }">Moderation</a>
|
||||
<a href="#" @click="currentTab = 'users'; refreshUsers()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'users' }">Users</a>
|
||||
<a href="#" @click="currentTab = 'analytics'; refreshAnalytics()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'analytics' }">Analytics</a>
|
||||
<a href="#" @click="currentTab = 'system'; refreshSystemSummary()" class="block px-4 py-2 rounded-lg" :class="{ 'bg-blue-600': currentTab === 'system' }">System (Redis)</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -41,26 +44,272 @@
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.viewers"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-blue-400 text-sm">Cache Hit Ratio</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.cacheHit + '%'">95%</div>
|
||||
<div class="text-blue-400 text-sm">Stored Messages</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.storedMessages"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-amber-400 text-sm">R2 Usage</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.r2Usage">12.4 GB</div>
|
||||
<div class="text-amber-400 text-sm">Moderation Actions</div>
|
||||
<div class="text-5xl font-bold mt-2" x-text="stats.moderationActions"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-sm text-slate-400" x-text="statusMessage"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'upload'">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-8">Upload Video</h1>
|
||||
<div class="flex items-start justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Upload Video</h1>
|
||||
<p class="text-sm text-slate-400 mt-2">Upload media files into the local media library mounted for playout.</p>
|
||||
</div>
|
||||
<button @click="refreshMediaLibrary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload library</button>
|
||||
</div>
|
||||
<div class="border-2 border-dashed border-slate-600 rounded-3xl p-12 text-center">
|
||||
<input type="file" id="video-upload" class="hidden" accept="video/*" @change="uploadVideo">
|
||||
<button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium">
|
||||
<input type="file" id="video-upload" class="hidden" accept="video/*,.mp4,.mov,.mkv,.webm" @change="uploadVideo">
|
||||
<button onclick="document.getElementById('video-upload').click()" class="bg-blue-600 hover:bg-blue-500 px-8 py-4 rounded-2xl text-lg font-medium" :disabled="media.uploading">
|
||||
Drag & Drop or Click to Upload
|
||||
</button>
|
||||
<div id="upload-status" class="mt-6 text-sm"></div>
|
||||
<div class="mt-6 text-sm" :class="media.error ? 'text-rose-400' : 'text-slate-400'" x-text="media.status"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-3xl p-6 mt-6">
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 class="text-lg font-semibold">Media Library</h2>
|
||||
<div class="text-xs text-slate-400" x-text="media.generatedAt"></div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-slate-400 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="text-left py-3 pr-4 font-medium">File</th>
|
||||
<th class="text-left py-3 pr-4 font-medium">Size</th>
|
||||
<th class="text-left py-3 font-medium">Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="media.files.length === 0">
|
||||
<tr>
|
||||
<td colspan="3" class="py-6 text-slate-500">No media files available.</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="file in media.files" :key="file.name">
|
||||
<tr class="border-b border-slate-800">
|
||||
<td class="py-3 pr-4" x-text="file.name"></td>
|
||||
<td class="py-3 pr-4" x-text="file.size"></td>
|
||||
<td class="py-3 font-mono text-xs" x-text="file.modifiedAt"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'schedule'">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Schedule</h1>
|
||||
<p class="text-sm text-slate-400 mt-2">Read-only view of the currently configured playout order.</p>
|
||||
</div>
|
||||
<button @click="refreshSchedule()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload schedule</button>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-3xl p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-slate-400 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="text-left py-3 pr-4 font-medium">#</th>
|
||||
<th class="text-left py-3 pr-4 font-medium">Label</th>
|
||||
<th class="text-left py-3 font-medium">Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="schedule.entries.length === 0">
|
||||
<tr>
|
||||
<td colspan="3" class="py-6 text-slate-500">No schedule entries found.</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="entry in schedule.entries" :key="entry.index + ':' + entry.path">
|
||||
<tr class="border-b border-slate-800">
|
||||
<td class="py-3 pr-4" x-text="entry.index"></td>
|
||||
<td class="py-3 pr-4" x-text="entry.label"></td>
|
||||
<td class="py-3 font-mono text-xs" x-text="entry.path"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 text-sm" :class="schedule.error ? 'text-rose-400' : 'text-slate-400'" x-text="schedule.status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'playlist'">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Playlist</h1>
|
||||
<p class="text-sm text-slate-400 mt-2">Edit the active concat playlist used by the playout service.</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="refreshPlaylist()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload</button>
|
||||
<button @click="savePlaylist()" class="bg-blue-600 hover:bg-blue-500 px-4 py-2 rounded-xl text-sm font-medium" :disabled="playlist.saving">
|
||||
<span x-text="playlist.saving ? 'Saving...' : 'Save playlist'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-3xl p-6">
|
||||
<textarea x-model="playlist.content" class="w-full min-h-[24rem] bg-slate-950 border border-slate-700 rounded-2xl p-4 font-mono text-sm focus:outline-none focus:border-blue-500" spellcheck="false"></textarea>
|
||||
<div class="mt-4 text-sm" :class="playlist.error ? 'text-rose-400' : 'text-slate-400'" x-text="playlist.status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'moderation'">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Moderation</h1>
|
||||
<p class="text-sm text-slate-400 mt-2">Create moderation actions and review the recent immutable audit log.</p>
|
||||
</div>
|
||||
<button @click="refreshModerationLogs()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload logs</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[24rem,1fr] gap-6">
|
||||
<div class="bg-slate-800 rounded-3xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">New Action</h2>
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm">
|
||||
<span class="block text-slate-400 mb-2">Action</span>
|
||||
<select x-model="moderation.form.action" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500">
|
||||
<option value="mute">mute</option>
|
||||
<option value="ban">ban</option>
|
||||
<option value="delete">delete</option>
|
||||
<option value="timeout">timeout</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="block text-slate-400 mb-2">Target</span>
|
||||
<input x-model="moderation.form.target" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="username or message id">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="block text-slate-400 mb-2">Moderator</span>
|
||||
<input x-model="moderation.form.moderator" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="admin">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="block text-slate-400 mb-2">Room</span>
|
||||
<input x-model="moderation.form.room" type="text" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="main">
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="block text-slate-400 mb-2">Reason</span>
|
||||
<textarea x-model="moderation.form.reason" class="w-full min-h-28 bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" placeholder="Explain why this action is needed"></textarea>
|
||||
</label>
|
||||
<button @click="submitModerationAction()" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium" :disabled="moderation.submitting">
|
||||
<span x-text="moderation.submitting ? 'Submitting...' : 'Submit action'"></span>
|
||||
</button>
|
||||
<div class="text-sm" :class="moderation.error ? 'text-rose-400' : 'text-slate-400'" x-text="moderation.status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800 rounded-3xl p-6 overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 class="text-lg font-semibold">Recent Audit Log</h2>
|
||||
<div class="text-xs text-slate-400">Latest 50 actions</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-slate-400 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="text-left py-3 pr-4 font-medium">When</th>
|
||||
<th class="text-left py-3 pr-4 font-medium">Action</th>
|
||||
<th class="text-left py-3 pr-4 font-medium">Target</th>
|
||||
<th class="text-left py-3 pr-4 font-medium">Moderator</th>
|
||||
<th class="text-left py-3 font-medium">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="moderation.logs.length === 0">
|
||||
<tr>
|
||||
<td colspan="5" class="py-6 text-slate-500">No moderation actions logged yet.</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="entry in moderation.logs" :key="entry.id">
|
||||
<tr class="border-b border-slate-800 align-top">
|
||||
<td class="py-3 pr-4 font-mono text-xs" x-text="entry.timestamp"></td>
|
||||
<td class="py-3 pr-4" x-text="entry.action"></td>
|
||||
<td class="py-3 pr-4" x-text="entry.target"></td>
|
||||
<td class="py-3 pr-4" x-text="entry.moderator"></td>
|
||||
<td class="py-3" x-text="entry.reason"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'analytics'">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Analytics</h1>
|
||||
<p class="text-sm text-slate-400 mt-2">Basic activity counters from chat history and moderation audit logs.</p>
|
||||
</div>
|
||||
<button @click="refreshAnalytics()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload analytics</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 xl:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-slate-400 text-sm">Total Messages</div>
|
||||
<div class="text-4xl font-bold mt-2" x-text="analytics.totalMessages"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-slate-400 text-sm">Last 24 Hours</div>
|
||||
<div class="text-4xl font-bold mt-2" x-text="analytics.messagesLast24Hours"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-slate-400 text-sm">Distinct Rooms</div>
|
||||
<div class="text-4xl font-bold mt-2" x-text="analytics.distinctRooms"></div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-6 rounded-3xl">
|
||||
<div class="text-slate-400 text-sm">Moderation Actions</div>
|
||||
<div class="text-4xl font-bold mt-2" x-text="analytics.moderationActions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800 rounded-3xl p-6">
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 class="text-lg font-semibold">Top Rooms</h2>
|
||||
<div class="text-xs text-slate-400" x-text="analytics.generatedAt"></div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-slate-400 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="text-left py-3 pr-4 font-medium">Room</th>
|
||||
<th class="text-left py-3 font-medium">Messages</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="analytics.topRooms.length === 0">
|
||||
<tr>
|
||||
<td colspan="2" class="py-6 text-slate-500">No room analytics available yet.</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="room in analytics.topRooms" :key="room.room">
|
||||
<tr class="border-b border-slate-800">
|
||||
<td class="py-3 pr-4" x-text="room.room"></td>
|
||||
<td class="py-3" x-text="room.messageCount"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 text-sm" :class="analytics.error ? 'text-rose-400' : 'text-slate-400'" x-text="analytics.status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -75,15 +324,15 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span>Memory Used</span>
|
||||
<span class="font-mono">248 MB</span>
|
||||
<span class="font-mono" x-text="system.redisMemoryUsed"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Connected Clients</span>
|
||||
<span class="font-mono">12</span>
|
||||
<span>Connected Redis Clients</span>
|
||||
<span class="font-mono" x-text="system.redisClients"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Rate Limit Threshold</span>
|
||||
<input type="number" value="10" class="bg-slate-700 px-3 py-1 rounded w-20 text-center">
|
||||
<span>Redis Enabled</span>
|
||||
<span class="font-mono" x-text="system.redisEnabled ? 'yes' : 'no'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,41 +340,462 @@
|
||||
<h3 class="font-medium mb-4">Storage</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span>SQLite Size</span>
|
||||
<span class="font-mono">142 MB</span>
|
||||
<span>Database Driver</span>
|
||||
<span class="font-mono" x-text="system.databaseDriver"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Stored Messages</span>
|
||||
<span class="font-mono" x-text="stats.storedMessages"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Last Refresh</span>
|
||||
<span class="font-mono" x-text="system.generatedAt"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Playlist Path</span>
|
||||
<span class="font-mono text-right" x-text="system.playlistPath"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Playlist Present</span>
|
||||
<span class="font-mono" x-text="system.playlistExists ? 'yes' : 'no'"></span>
|
||||
</div>
|
||||
<button onclick="runVacuum()" class="bg-slate-700 hover:bg-slate-600 px-6 py-2 rounded-xl">Run VACUUM</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-800 p-8 rounded-3xl mt-6">
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<h2 class="text-lg font-semibold">Service Health</h2>
|
||||
<button @click="refreshSystemSummary()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload health</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<template x-for="service in system.services" :key="service.name">
|
||||
<div class="flex items-start justify-between gap-6 border-b border-slate-700 pb-4">
|
||||
<div>
|
||||
<div class="font-medium" x-text="service.name"></div>
|
||||
<div class="text-xs text-slate-400 mt-1" x-text="service.checked"></div>
|
||||
</div>
|
||||
<div class="text-right max-w-xl">
|
||||
<div class="uppercase tracking-wide text-xs" :class="service.status === 'healthy' ? 'text-emerald-400' : service.status === 'disabled' ? 'text-amber-400' : 'text-rose-400'" x-text="service.status"></div>
|
||||
<div class="text-sm text-slate-300 mt-1 break-all" x-text="service.detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-4 text-sm" :class="system.error ? 'text-rose-400' : 'text-slate-400'" x-text="system.status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="currentTab === 'users'">
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-6 mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Users</h1>
|
||||
<p class="text-sm text-slate-400 mt-2">Current room presence derived from Redis presence keys.</p>
|
||||
</div>
|
||||
<button @click="refreshUsers()" class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm">Reload users</button>
|
||||
</div>
|
||||
<div class="bg-slate-800 rounded-3xl p-6">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-slate-400 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="text-left py-3 pr-4 font-medium">Username</th>
|
||||
<th class="text-left py-3 pr-4 font-medium">Room</th>
|
||||
<th class="text-left py-3 font-medium">Online</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-if="users.entries.length === 0">
|
||||
<tr>
|
||||
<td colspan="3" class="py-6 text-slate-500">No active users reported.</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="entry in users.entries" :key="entry.room + ':' + entry.username">
|
||||
<tr class="border-b border-slate-800">
|
||||
<td class="py-3 pr-4" x-text="entry.username"></td>
|
||||
<td class="py-3 pr-4" x-text="entry.room"></td>
|
||||
<td class="py-3" x-text="entry.online ? 'yes' : 'no'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 text-sm" :class="users.error ? 'text-rose-400' : 'text-slate-400'" x-text="users.status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const adminConfig = (() => {
|
||||
const { dataset } = document.body;
|
||||
const moderationLogLimit = Number.parseInt(dataset.adminModerationLogLimit || '', 10);
|
||||
|
||||
return {
|
||||
defaultModerator: dataset.adminDefaultModerator || 'admin',
|
||||
defaultRoom: dataset.adminDefaultRoom || 'main',
|
||||
moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50
|
||||
};
|
||||
})();
|
||||
|
||||
function admin() {
|
||||
return {
|
||||
currentTab: 'dashboard',
|
||||
stats: {
|
||||
viewers: 142,
|
||||
cacheHit: 97,
|
||||
r2Usage: '18.4 GB'
|
||||
viewers: 0,
|
||||
storedMessages: 0,
|
||||
moderationActions: 0
|
||||
},
|
||||
system: {
|
||||
redisEnabled: false,
|
||||
redisClients: 0,
|
||||
redisMemoryUsed: 'loading...',
|
||||
databaseDriver: 'loading...',
|
||||
generatedAt: 'loading...',
|
||||
playlistPath: 'loading...',
|
||||
playlistExists: false,
|
||||
services: [],
|
||||
status: 'Loading system summary...',
|
||||
error: false
|
||||
},
|
||||
analytics: {
|
||||
totalMessages: 0,
|
||||
messagesLast24Hours: 0,
|
||||
distinctRooms: 0,
|
||||
moderationActions: 0,
|
||||
topRooms: [],
|
||||
generatedAt: 'loading...',
|
||||
status: 'Loading analytics...',
|
||||
error: false
|
||||
},
|
||||
schedule: {
|
||||
entries: [],
|
||||
status: 'Loading schedule...',
|
||||
error: false
|
||||
},
|
||||
playlist: {
|
||||
content: '',
|
||||
status: 'Loading playlist...',
|
||||
saving: false,
|
||||
error: false
|
||||
},
|
||||
media: {
|
||||
files: [],
|
||||
generatedAt: 'loading...',
|
||||
uploading: false,
|
||||
status: 'Loading media library...',
|
||||
error: false
|
||||
},
|
||||
moderation: {
|
||||
logs: [],
|
||||
submitting: false,
|
||||
error: false,
|
||||
status: 'Loading moderation logs...',
|
||||
form: {
|
||||
action: 'mute',
|
||||
target: '',
|
||||
moderator: adminConfig.defaultModerator,
|
||||
reason: '',
|
||||
room: adminConfig.defaultRoom
|
||||
}
|
||||
},
|
||||
users: {
|
||||
entries: [],
|
||||
status: 'Loading active users...',
|
||||
error: false
|
||||
},
|
||||
statusMessage: 'Loading live admin overview...',
|
||||
async refreshOverview() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/overview');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const overview = await response.json();
|
||||
this.stats.viewers = overview.viewers;
|
||||
this.stats.storedMessages = overview.storedMessages;
|
||||
this.stats.moderationActions = overview.moderationActions;
|
||||
this.system.redisEnabled = overview.redisEnabled;
|
||||
this.system.redisClients = overview.redisClients;
|
||||
this.system.redisMemoryUsed = overview.redisMemoryUsed;
|
||||
this.system.databaseDriver = overview.databaseDriver;
|
||||
this.system.generatedAt = overview.generatedAt;
|
||||
this.statusMessage = `Live data loaded at ${overview.generatedAt}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin overview', error);
|
||||
this.statusMessage = 'Failed to load live admin data.';
|
||||
this.system.redisMemoryUsed = 'unavailable';
|
||||
this.system.databaseDriver = 'unavailable';
|
||||
this.system.generatedAt = 'unavailable';
|
||||
}
|
||||
},
|
||||
async refreshAnalytics() {
|
||||
this.analytics.status = 'Loading analytics...';
|
||||
this.analytics.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/analytics');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.analytics.totalMessages = payload.totalMessages;
|
||||
this.analytics.messagesLast24Hours = payload.messagesLast24Hours;
|
||||
this.analytics.distinctRooms = payload.distinctRooms;
|
||||
this.analytics.moderationActions = payload.moderationActions;
|
||||
this.analytics.topRooms = payload.topRooms || [];
|
||||
this.analytics.generatedAt = payload.generatedAt;
|
||||
this.analytics.status = 'Analytics loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics', error);
|
||||
this.analytics.status = 'Failed to load analytics.';
|
||||
this.analytics.error = true;
|
||||
}
|
||||
},
|
||||
async refreshPlaylist() {
|
||||
this.playlist.status = 'Loading playlist...';
|
||||
this.playlist.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/playlist');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.playlist.content = payload.content;
|
||||
this.playlist.status = 'Playlist loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlist', error);
|
||||
this.playlist.status = 'Failed to load playlist.';
|
||||
this.playlist.error = true;
|
||||
}
|
||||
},
|
||||
async refreshSchedule() {
|
||||
this.schedule.status = 'Loading schedule...';
|
||||
this.schedule.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/schedule');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.schedule.entries = payload.entries || [];
|
||||
this.schedule.status = 'Schedule loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load schedule', error);
|
||||
this.schedule.status = 'Failed to load schedule.';
|
||||
this.schedule.error = true;
|
||||
}
|
||||
},
|
||||
async refreshSystemSummary() {
|
||||
this.system.status = 'Loading system summary...';
|
||||
this.system.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/system');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.system.databaseDriver = payload.databaseDriver;
|
||||
this.system.playlistPath = payload.playlistPath;
|
||||
this.system.playlistExists = payload.playlistExists;
|
||||
this.system.redisEnabled = payload.redisEnabled;
|
||||
this.system.redisMemoryUsed = payload.redisMemoryUsed;
|
||||
this.system.services = payload.services || [];
|
||||
this.system.generatedAt = payload.generatedAt;
|
||||
this.system.status = 'System summary loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load system summary', error);
|
||||
this.system.status = 'Failed to load system summary.';
|
||||
this.system.error = true;
|
||||
}
|
||||
},
|
||||
async refreshMediaLibrary() {
|
||||
this.media.status = 'Loading media library...';
|
||||
this.media.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/media');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.media.files = payload.files || [];
|
||||
this.media.generatedAt = payload.generatedAt;
|
||||
this.media.status = 'Media library loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load media library', error);
|
||||
this.media.status = 'Failed to load media library.';
|
||||
this.media.error = true;
|
||||
}
|
||||
},
|
||||
async savePlaylist() {
|
||||
this.playlist.saving = true;
|
||||
this.playlist.status = 'Saving playlist...';
|
||||
this.playlist.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/playlist', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ content: this.playlist.content })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.playlist.content = payload.content;
|
||||
this.playlist.status = 'Playlist saved.';
|
||||
} catch (error) {
|
||||
console.error('Failed to save playlist', error);
|
||||
this.playlist.status = 'Failed to save playlist.';
|
||||
this.playlist.error = true;
|
||||
} finally {
|
||||
this.playlist.saving = false;
|
||||
}
|
||||
},
|
||||
async refreshModerationLogs() {
|
||||
this.moderation.status = 'Loading moderation logs...';
|
||||
this.moderation.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.moderation.logs = payload.logs || [];
|
||||
this.moderation.status = 'Moderation logs loaded.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load moderation logs', error);
|
||||
this.moderation.status = 'Failed to load moderation logs.';
|
||||
this.moderation.error = true;
|
||||
}
|
||||
},
|
||||
async submitModerationAction() {
|
||||
this.moderation.submitting = true;
|
||||
this.moderation.error = false;
|
||||
this.moderation.status = 'Submitting moderation action...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/moderation/actions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.moderation.form)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
this.moderation.status = 'Moderation action recorded.';
|
||||
this.moderation.form.target = '';
|
||||
this.moderation.form.reason = '';
|
||||
await this.refreshModerationLogs();
|
||||
await this.refreshOverview();
|
||||
} catch (error) {
|
||||
console.error('Failed to submit moderation action', error);
|
||||
this.moderation.status = 'Failed to submit moderation action.';
|
||||
this.moderation.error = true;
|
||||
} finally {
|
||||
this.moderation.submitting = false;
|
||||
}
|
||||
},
|
||||
async refreshUsers() {
|
||||
this.users.status = 'Loading active users...';
|
||||
this.users.error = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/users');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
this.users.entries = payload.users || [];
|
||||
this.users.status = payload.redisEnabled ? 'Active users loaded.' : 'Redis disabled; no live presence data available.';
|
||||
} catch (error) {
|
||||
console.error('Failed to load users', error);
|
||||
this.users.status = 'Failed to load active users.';
|
||||
this.users.error = true;
|
||||
}
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await fetch('/api/admin/logout', { method: 'POST' });
|
||||
} finally {
|
||||
window.location.href = '/admin/login/';
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.refreshOverview();
|
||||
this.refreshAnalytics();
|
||||
this.refreshMediaLibrary();
|
||||
this.refreshPlaylist();
|
||||
this.refreshSchedule();
|
||||
this.refreshModerationLogs();
|
||||
this.refreshSystemSummary();
|
||||
this.refreshUsers();
|
||||
console.log('Admin Interface initialized (ADR-004/ADR-005)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function uploadVideo(event) {
|
||||
async function uploadVideo(event) {
|
||||
const file = event.target.files[0];
|
||||
const status = document.getElementById('upload-status');
|
||||
status.innerHTML = `Uploading ${file.name}... (normalization in progress)`;
|
||||
setTimeout(() => {
|
||||
status.innerHTML = `<span class="text-emerald-400">✓ ${file.name} normalized and uploaded to R2</span>`;
|
||||
}, 1500);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const component = Alpine.$data(document.querySelector('[x-data="admin()"]'));
|
||||
component.media.uploading = true;
|
||||
component.media.error = false;
|
||||
component.media.status = `Uploading ${file.name}...`;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/admin/media', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
component.media.files = payload.files || [];
|
||||
component.media.generatedAt = payload.generatedAt;
|
||||
component.media.status = `${file.name} uploaded.`;
|
||||
document.getElementById('video-upload').value = '';
|
||||
} catch (error) {
|
||||
console.error('Failed to upload media file', error);
|
||||
component.media.status = 'Failed to upload media file.';
|
||||
component.media.error = true;
|
||||
} finally {
|
||||
component.media.uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function runVacuum() {
|
||||
|
||||
Reference in new issue
Block a user