chore: release v1.0.7
Playout: slate fallback, strict ffprobe validation, Prometheus metrics Chat: history on join, DM offline delivery, env-config rate limits, Redis auto-reconnect, heartbeat, ban/mute enforcement, DM metrics Viewer: presence indicators, schedule overlay, public /api/schedule Admin: playlist drag-reorder, schedule overrides CRUD, user actions, inline mod buttons, CSV export, config editor, restart buttons, upload normalization gate, analytics time range nginx: CORS preflight, JSON health, stub_status Docs: test-lab vs production distinction across all configs/docs Infra: Redis maxmemory, CD health IP-agnostic, slate volume mount
This commit is contained in:
1 parent
17ac04856b
commit
5b5cdc4cd8
26 files changed
+1723
-110
No files matched your search
+235
-3
@@ -259,10 +259,13 @@ async function refreshPlaylist() {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
const content: string = payload.content || "";
|
||||
const textarea = document.getElementById(
|
||||
"playlist-content",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) textarea.value = payload.content || "";
|
||||
if (textarea) textarea.value = content;
|
||||
renderPlaylistSortable(content);
|
||||
await showPlaylistWarnings(content);
|
||||
setStatus("playlist-status", "Playlist loaded.");
|
||||
} catch (error) {
|
||||
console.error("Failed to load playlist", error);
|
||||
@@ -270,6 +273,95 @@ async function refreshPlaylist() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderPlaylistSortable(content: string) {
|
||||
const list = document.getElementById("playlist-sortable");
|
||||
if (!list) return;
|
||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
list.innerHTML = lines
|
||||
.map((line, i) => {
|
||||
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||
<span class="sortable-index">${i + 1}</span>
|
||||
<span class="sortable-path">${path}</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
let dragSrc: number | null = null;
|
||||
list.querySelectorAll<HTMLElement>(".sortable-item").forEach((item) => {
|
||||
item.addEventListener("dragstart", () => {
|
||||
dragSrc = Number(item.dataset.index);
|
||||
item.classList.add("dragging");
|
||||
});
|
||||
item.addEventListener("dragend", () => {
|
||||
item.classList.remove("dragging");
|
||||
dragSrc = null;
|
||||
});
|
||||
item.addEventListener("dragover", (e) => e.preventDefault());
|
||||
item.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
const dst = Number(item.dataset.index);
|
||||
if (dragSrc !== null && dragSrc !== dst) reorderPlaylistEntry(dragSrc, dst);
|
||||
});
|
||||
item.querySelector("[data-remove]")?.addEventListener("click", () => {
|
||||
removePlaylistEntry(Number(item.dataset.index));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getPlaylistLines(): string[] {
|
||||
const ta = document.getElementById("playlist-content") as HTMLTextAreaElement;
|
||||
return ta?.value.split("\n").filter((l) => l.trim() && !l.startsWith("#")) || [];
|
||||
}
|
||||
|
||||
function syncPlaylistTextarea(lines: string[]) {
|
||||
const ta = document.getElementById("playlist-content") as HTMLTextAreaElement;
|
||||
if (ta) ta.value = lines.join("\n") + "\n";
|
||||
renderPlaylistSortable(lines.join("\n"));
|
||||
}
|
||||
|
||||
function reorderPlaylistEntry(from: number, to: number) {
|
||||
const lines = getPlaylistLines();
|
||||
const [moved] = lines.splice(from, 1);
|
||||
lines.splice(to, 0, moved);
|
||||
syncPlaylistTextarea(lines);
|
||||
}
|
||||
|
||||
function removePlaylistEntry(index: number) {
|
||||
const lines = getPlaylistLines();
|
||||
lines.splice(index, 1);
|
||||
syncPlaylistTextarea(lines);
|
||||
}
|
||||
|
||||
async function showPlaylistWarnings(content: string) {
|
||||
const warnings = document.getElementById("playlist-warnings");
|
||||
if (!warnings) return;
|
||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
const msgs: string[] = [];
|
||||
if (lines.length === 0) msgs.push("Playlist is empty. Add files from the media library.");
|
||||
try {
|
||||
const resp = await fetch("/api/admin/media");
|
||||
if (resp.ok) {
|
||||
const media = await resp.json();
|
||||
const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path));
|
||||
const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization");
|
||||
for (const line of lines) {
|
||||
const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim();
|
||||
if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`);
|
||||
}
|
||||
if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (msgs.length > 0) {
|
||||
warnings.innerHTML = msgs.map((m) => `<div class="warning-msg">⚠ ${m}</div>`).join("");
|
||||
warnings.hidden = false;
|
||||
} else {
|
||||
warnings.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlaylist() {
|
||||
if (state.playlistSaving) return;
|
||||
state.playlistSaving = true;
|
||||
@@ -333,6 +425,55 @@ async function refreshSchedule() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOverrides() {
|
||||
try {
|
||||
const resp = await fetch("/api/admin/schedule/overrides");
|
||||
if (!resp.ok) return;
|
||||
const overrides = await resp.json();
|
||||
const tbody = document.getElementById("override-table-body");
|
||||
if (!tbody) return;
|
||||
if (!overrides.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="5">No overrides.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = overrides
|
||||
.map(
|
||||
(o: { id: number; mediaId: number; startTime: string; endTime: string; reason: string }) =>
|
||||
`<tr><td>${o.mediaId}</td><td>${new Date(o.startTime).toLocaleString()}</td><td>${new Date(o.endTime).toLocaleString()}</td><td>${o.reason || "-"}</td><td><button class="btn btn-secondary btn-sm" data-delete-override="${o.id}">✕</button></td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
tbody.querySelectorAll("[data-delete-override]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride)));
|
||||
});
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function submitOverride(e: Event) {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById("override-status");
|
||||
if (status) status.textContent = "Saving...";
|
||||
const mediaId = (document.getElementById("override-media-id") as HTMLInputElement).value;
|
||||
const startTime = (document.getElementById("override-start") as HTMLInputElement).value;
|
||||
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
|
||||
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
|
||||
try {
|
||||
const resp = await fetch("/api/admin/schedule/overrides", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }),
|
||||
});
|
||||
if (!resp.ok) { const text = await resp.text(); throw new Error(text); }
|
||||
if (status) { status.textContent = "Override added."; status.className = "status-line"; }
|
||||
refreshOverrides();
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOverride(id: number) {
|
||||
try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function refreshSystemSummary() {
|
||||
setStatus("system-status", "Loading system summary...");
|
||||
try {
|
||||
@@ -756,9 +897,17 @@ function renderChatLogTable() {
|
||||
<td>${entry.type}</td>
|
||||
<td>${entry.timestamp}</td>
|
||||
<td class="action-cell">
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete-chat" data-id="${entry.id}">Delete</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-mute" data-user="${entry.username}">Mute</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-action="chat-ban" data-user="${entry.username}">Ban</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-action="delete-chat" data-id="${entry.id}">Del</button>
|
||||
</td>
|
||||
`;
|
||||
tr.querySelector('[data-action="chat-mute"]')?.addEventListener("click", () => {
|
||||
userAction("mute", entry.username);
|
||||
});
|
||||
tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => {
|
||||
if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username);
|
||||
});
|
||||
tr.querySelector('[data-action="delete-chat"]')?.addEventListener(
|
||||
"click",
|
||||
() => deleteChatEntry(entry.id),
|
||||
@@ -930,9 +1079,19 @@ async function refreshUsers() {
|
||||
} else {
|
||||
users.forEach((user: any) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>`;
|
||||
tr.innerHTML = `<td>${user.username}</td><td>${user.room}</td><td>${user.online ? "yes" : "no"}</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-sm user-mute" data-user="${user.username}">Mute 10m</button>
|
||||
<button class="btn btn-secondary btn-sm user-ban" data-user="${user.username}">Ban</button>
|
||||
<button class="btn btn-secondary btn-sm user-unmute" data-user="${user.username}">Unmute</button>
|
||||
<button class="btn btn-secondary btn-sm user-unban" data-user="${user.username}">Unban</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!)));
|
||||
tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!)));
|
||||
}
|
||||
|
||||
setStatus(
|
||||
@@ -947,6 +1106,63 @@ async function refreshUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function userAction(action: string, username: string) {
|
||||
try {
|
||||
const resp = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, username, duration: 10 }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
setStatus("users-status", `${action} ${username} OK.`);
|
||||
} catch (err) {
|
||||
setStatus("users-status", `Failed: ${err}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const resp = await fetch("/api/admin/config");
|
||||
if (!resp.ok) return;
|
||||
const cfg = await resp.json();
|
||||
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function saveConfig(e: Event) {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById("config-status");
|
||||
if (status) status.textContent = "Saving...";
|
||||
try {
|
||||
const resp = await fetch("/api/admin/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value),
|
||||
rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (status) { status.textContent = "Config saved."; status.className = "status-line"; }
|
||||
} catch (err) {
|
||||
if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; }
|
||||
}
|
||||
}
|
||||
|
||||
async function restartService(service: string) {
|
||||
if (!confirm(`Restart ${service}? This may cause brief interruption.`)) return;
|
||||
const status = document.getElementById("restart-status");
|
||||
if (status) status.textContent = `Requesting restart of ${service}...`;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/restart?service=${service}`, { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (status) status.textContent = data.message;
|
||||
} catch (err) {
|
||||
if (status) status.textContent = `Failed: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>(".tab-trigger")
|
||||
@@ -1017,12 +1233,21 @@ function bindPageEvents(tabName: string) {
|
||||
document
|
||||
.getElementById("reload-schedule-btn")
|
||||
?.addEventListener("click", refreshSchedule);
|
||||
document
|
||||
.getElementById("override-form")
|
||||
?.addEventListener("submit", submitOverride);
|
||||
refreshOverrides();
|
||||
break;
|
||||
|
||||
case "moderation":
|
||||
document
|
||||
.getElementById("reload-moderation-btn")
|
||||
?.addEventListener("click", refreshModerationLogs);
|
||||
document
|
||||
.getElementById("export-chat-log-btn")
|
||||
?.addEventListener("click", () => {
|
||||
window.open("/api/admin/chat-log/export", "_blank");
|
||||
});
|
||||
document
|
||||
.getElementById("moderation-form")
|
||||
?.addEventListener("submit", submitModerationAction);
|
||||
@@ -1083,6 +1308,13 @@ function bindPageEvents(tabName: string) {
|
||||
document
|
||||
.getElementById("reload-system-btn")
|
||||
?.addEventListener("click", refreshSystemSummary);
|
||||
document
|
||||
.getElementById("config-form")
|
||||
?.addEventListener("submit", saveConfig);
|
||||
document.querySelectorAll(".restart-btn").forEach((btn) =>
|
||||
btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))
|
||||
);
|
||||
loadConfig();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let chatVisible = true;
|
||||
const onlineUsers = new Set<string>();
|
||||
|
||||
function resolveWebSocketBaseUrl() {
|
||||
const configuredUrl = import.meta.env.VITE_PUBLIC_WS_URL?.trim();
|
||||
@@ -74,6 +75,19 @@ function connectWebSocket(room: string) {
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||
|
||||
// Track presence from system join/leave messages
|
||||
if (msg.type === "system" && msg.username === "system") {
|
||||
const joined = msg.text?.match(/^(\S+) joined /);
|
||||
const left = msg.text?.match(/^(\S+) left /);
|
||||
if (joined) {
|
||||
onlineUsers.add(joined[1]);
|
||||
renderPresence();
|
||||
} else if (left) {
|
||||
onlineUsers.delete(left[1]);
|
||||
renderPresence();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
@@ -112,6 +126,37 @@ function addMessage(
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
const list = document.getElementById("presence-list");
|
||||
if (!list) return;
|
||||
const sorted = [...onlineUsers].sort();
|
||||
list.innerHTML = sorted
|
||||
.map(
|
||||
(u) =>
|
||||
`<span class="presence-dot" title="${u}"><span class="presence-indicator"></span>${u}</span>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function refreshSchedule() {
|
||||
try {
|
||||
const res = await fetch("/api/schedule");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const entries = data.entries as Array<{ title: string; status: string }>;
|
||||
if (entries.length > 0) {
|
||||
const np = document.getElementById("np-title");
|
||||
const nn = document.getElementById("np-next");
|
||||
if (np) np.textContent = `Now: ${entries[0].title}`;
|
||||
if (nn && entries.length > 1) {
|
||||
nn.textContent = `Up next: ${entries[1].title}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Schedule unavailable — non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function initPlayerAndChat() {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
@@ -136,6 +181,10 @@ async function initPlayerAndChat() {
|
||||
|
||||
connectWebSocket("main");
|
||||
|
||||
// Start schedule polling
|
||||
refreshSchedule();
|
||||
setInterval(refreshSchedule, 30000);
|
||||
|
||||
console.log(
|
||||
"Linear Void Frontend initialized - HLS Player + Chat Sidebar ready",
|
||||
);
|
||||
|
||||
@@ -191,6 +191,74 @@ select {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sortable-list {
|
||||
list-style: none;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sortable-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
cursor: grab;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sortable-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.sortable-item:hover {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.sortable-item.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 1px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sortable-index {
|
||||
font-size: 0.7rem;
|
||||
color: var(--ink-soft);
|
||||
min-width: 1.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sortable-path {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-0);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-warnings {
|
||||
background: rgba(212, 150, 36, 0.1);
|
||||
border: 1px solid var(--warn);
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.warning-msg {
|
||||
font-size: 0.8rem;
|
||||
color: var(--warn);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-1);
|
||||
@@ -454,6 +522,21 @@ select {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.now-playing-overlay {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-0);
|
||||
}
|
||||
|
||||
.np-next {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.now-playing-overlay {
|
||||
position: absolute;
|
||||
right: calc(var(--space-5) + 0.7rem);
|
||||
@@ -524,6 +607,35 @@ select {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.presence-panel {
|
||||
padding: 0.5rem 1rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.presence-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.presence-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-1);
|
||||
}
|
||||
|
||||
.presence-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ok);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user