feat: add admin login page and authentication API
CD / deploy (push) Failing after 27s
CI / test (push) Successful in 1m54s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s

- 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:
zwitschi committed 2026-08-03 17:19:05 +02:00
1 parent 1ae0ad121b
commit 6b494a34fe
15 files changed
+1896 -52

No files matched your search

+702 -32
View File
@@ -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() {
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Linear Void Admin Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="../../src/style.css" />
</head>
<body class="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center px-6">
<main class="w-full max-w-md bg-slate-900 border border-slate-800 rounded-[2rem] shadow-2xl p-8">
<div class="mb-8">
<div class="text-xs uppercase tracking-[0.3em] text-slate-500 mb-3">Linear Void</div>
<h1 class="text-4xl font-semibold text-white mb-2">Admin Login</h1>
<p class="text-sm text-slate-400">Authenticate to access protected admin controls.</p>
</div>
<form id="login-form" class="space-y-4">
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Username</span>
<input id="username" 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" autocomplete="username" required />
</label>
<label class="block text-sm">
<span class="block text-slate-400 mb-2">Password</span>
<input id="password" type="password" class="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 focus:outline-none focus:border-blue-500" autocomplete="current-password" required />
</label>
<button id="submit" type="submit" class="w-full bg-blue-600 hover:bg-blue-500 px-4 py-3 rounded-xl font-medium text-white">Sign in</button>
<div id="status" class="text-sm text-slate-400 min-h-6"></div>
</form>
</main>
<script>
const form = document.getElementById('login-form');
const username = document.getElementById('username');
const password = document.getElementById('password');
const submit = document.getElementById('submit');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
submit.disabled = true;
status.textContent = 'Signing in...';
status.className = 'text-sm text-slate-400 min-h-6';
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
window.location.href = '/admin/';
} catch (error) {
console.error('Failed to sign in', error);
status.textContent = 'Login failed. Check your credentials.';
status.className = 'text-sm text-rose-400 min-h-6';
} finally {
submit.disabled = false;
}
});
</script>
</body>
</html>