feat: initialise project structure with core encoding queue, database models, and web management API
Build / Build and Push (push) Successful in 50s
Build / Build and Push (push) Successful in 50s
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
{{define "content"}}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div class="card" style="text-align: center; padding: 1.5rem;">
|
||||
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">Files Encoded</h3>
|
||||
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.FilesEncoded}}</div>
|
||||
</div>
|
||||
<div class="card" style="text-align: center; padding: 1.5rem;">
|
||||
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">Space Saved</h3>
|
||||
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.SavedSpace}}</div>
|
||||
</div>
|
||||
<div class="card" style="text-align: center; padding: 1.5rem;">
|
||||
<h3 style="margin-top: 0; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase;">In Queue</h3>
|
||||
<div style="font-size: 2rem; font-weight: 700; color: var(--accent);">{{.QueueLength}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Active Job Queue</h2>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>File</th>
|
||||
<th>Type</th>
|
||||
<th>Priority</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="queue-body">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const queueBody = document.getElementById('queue-body');
|
||||
|
||||
async function loadQueue() {
|
||||
const res = await fetch('/api/queue');
|
||||
const jobs = await res.json();
|
||||
renderQueue(jobs || []);
|
||||
}
|
||||
|
||||
function renderQueue(jobs) {
|
||||
queueBody.innerHTML = jobs.map(job => {
|
||||
let statusBadge = `<span class="badge ${job.status}">${job.status}</span>`;
|
||||
|
||||
let progressHtml = '';
|
||||
if (job.status === 'processing') {
|
||||
progressHtml = `
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress-${job.id}" style="width: 0%"></div>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;" id="progress-text-${job.id}">0%</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let actions = '';
|
||||
if (job.status === 'pending') {
|
||||
actions = `
|
||||
<button class="btn btn-sm btn-outline" onclick="bumpJob(${job.id})">Bump</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="cancelJob(${job.id})">Cancel</button>
|
||||
`;
|
||||
} else if (job.status === 'processing') {
|
||||
actions = `
|
||||
<button class="btn btn-sm btn-danger" onclick="cancelJob(${job.id})">Cancel</button>
|
||||
`;
|
||||
} else if (job.status === 'failed') {
|
||||
// Retry not fully implemented in API yet, but could re-enqueue
|
||||
actions = `<span style="color: var(--error); font-size: 0.75rem;">${job.error_message}</span>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr id="job-${job.id}">
|
||||
<td>${job.id}</td>
|
||||
<td style="word-break: break-all;">
|
||||
${job.file_path.split('/').pop()}
|
||||
${progressHtml}
|
||||
</td>
|
||||
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">${job.media_type}</span></td>
|
||||
<td>${job.priority}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${actions}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function bumpJob(id) {
|
||||
await fetch('/api/jobs/bump/' + id, { method: 'POST' });
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
async function cancelJob(id) {
|
||||
if (!confirm("Cancel job?")) return;
|
||||
await fetch('/api/jobs/cancel/' + id, { method: 'POST' });
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
// SSE listeners
|
||||
window.sse.addEventListener('queue_updated', () => loadQueue());
|
||||
window.sse.addEventListener('job_started', () => loadQueue());
|
||||
window.sse.addEventListener('job_completed', () => loadQueue());
|
||||
window.sse.addEventListener('job_failed', () => loadQueue());
|
||||
window.sse.addEventListener('job_added', () => loadQueue());
|
||||
|
||||
window.sse.addEventListener('progress', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const fill = document.getElementById(`progress-${data.id}`);
|
||||
const text = document.getElementById(`progress-text-${data.id}`);
|
||||
if (fill && text) {
|
||||
fill.style.width = `${data.progress}%`;
|
||||
text.innerText = `${data.progress}%`;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load
|
||||
loadQueue();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,152 @@
|
||||
{{define "content"}}
|
||||
<div style="display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap;">
|
||||
<div class="card" style="flex: 2; min-width: 600px;">
|
||||
<h2>Configured Watch Folders</h2>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Path</th>
|
||||
<th>Type</th>
|
||||
<th>Resolution</th>
|
||||
<th>Flags</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="folders-body">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="flex: 1; min-width: 300px;">
|
||||
<h2>Add Watch Folder</h2>
|
||||
<form id="add-folder-form" onsubmit="addFolder(event)">
|
||||
<div class="form-group">
|
||||
<label>Folder Path</label>
|
||||
<input type="text" id="folder_path" class="form-control" placeholder="/media/movies" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Media Type</label>
|
||||
<select id="media_type" class="form-control" required>
|
||||
<option value="video">Video</option>
|
||||
<option value="audio">Audio</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Target Resolution (Video only)</label>
|
||||
<select id="target_resolution" class="form-control">
|
||||
<option value="">Original</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="720p">720p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="video_crf_group">
|
||||
<label>CRF (Video only, 0-51)</label>
|
||||
<input type="number" id="crf" class="form-control" placeholder="22" min="0" max="51">
|
||||
</div>
|
||||
<div class="form-group" id="video_preset_group">
|
||||
<label>Preset (Video only)</label>
|
||||
<select id="preset" class="form-control">
|
||||
<option value="">Default (medium)</option>
|
||||
<option value="ultrafast">ultrafast</option>
|
||||
<option value="superfast">superfast</option>
|
||||
<option value="veryfast">veryfast</option>
|
||||
<option value="faster">faster</option>
|
||||
<option value="fast">fast</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="slow">slow</option>
|
||||
<option value="slower">slower</option>
|
||||
<option value="veryslow">veryslow</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Custom FFmpeg Flags (Optional)</label>
|
||||
<input type="text" id="custom_ffmpeg_flags" class="form-control" placeholder="-vcodec copy">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="width: 100%;">Add Folder</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const foldersBody = document.getElementById('folders-body');
|
||||
|
||||
async function loadFolders() {
|
||||
const res = await fetch('/api/folders');
|
||||
const folders = await res.json();
|
||||
renderFolders(folders || []);
|
||||
}
|
||||
|
||||
function renderFolders(folders) {
|
||||
foldersBody.innerHTML = folders.map(f => `
|
||||
<tr>
|
||||
<td style="word-break: break-all;">${f.folder_path}</td>
|
||||
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">${f.media_type}</span></td>
|
||||
<td>${f.target_resolution || '-'}</td>
|
||||
<td><code style="background: var(--bg); padding: 0.2rem; border-radius: 4px;">${f.custom_ffmpeg_flags || '-'}</code></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteFolder(${f.id})">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function addFolder(e) {
|
||||
e.preventDefault();
|
||||
|
||||
let flags = document.getElementById('custom_ffmpeg_flags').value;
|
||||
const crf = document.getElementById('crf').value;
|
||||
const preset = document.getElementById('preset').value;
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
|
||||
if (mediaType === 'video') {
|
||||
if (crf) {
|
||||
flags += (flags ? ' ' : '') + '-crf ' + crf;
|
||||
}
|
||||
if (preset) {
|
||||
flags += (flags ? ' ' : '') + '-preset ' + preset;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
folder_path: document.getElementById('folder_path').value,
|
||||
media_type: mediaType,
|
||||
target_resolution: document.getElementById('target_resolution').value,
|
||||
custom_ffmpeg_flags: flags,
|
||||
};
|
||||
const res = await fetch('/api/folders/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
document.getElementById('add-folder-form').reset();
|
||||
loadFolders();
|
||||
} else {
|
||||
alert('Failed to add folder');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(id) {
|
||||
if (!confirm('Delete watch folder?')) return;
|
||||
const res = await fetch('/api/folders/delete/' + id, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
loadFolders();
|
||||
} else {
|
||||
alert('Failed to delete folder');
|
||||
}
|
||||
}
|
||||
|
||||
loadFolders();
|
||||
|
||||
document.getElementById('media_type').addEventListener('change', function (e) {
|
||||
const isVideo = e.target.value === 'video';
|
||||
document.getElementById('target_resolution').parentElement.style.display = isVideo ? 'block' : 'none';
|
||||
document.getElementById('video_crf_group').style.display = isVideo ? 'block' : 'none';
|
||||
document.getElementById('video_preset_group').style.display = isVideo ? 'block' : 'none';
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,80 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">History</h2>
|
||||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||
<input type="text" id="history-search" class="form-control" placeholder="Search history..." onkeyup="filterHistory()" style="max-width: 300px;">
|
||||
<span style="color: var(--text-secondary); font-size: 0.875rem; white-space: nowrap;">Total Records: <span id="record-count">{{.Total}}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Original Size</th>
|
||||
<th>Encoded Size</th>
|
||||
<th>Saved</th>
|
||||
<th>Time</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Reports}}
|
||||
<tr>
|
||||
<td style="word-break: break-all;">
|
||||
<div style="font-weight: 500;">{{.FilePath}}</div>
|
||||
{{if .ErrorMessage}}
|
||||
<div style="color: var(--error); font-size: 0.75rem; margin-top: 0.25rem;">{{.ErrorMessage}}</div>
|
||||
{{end}}
|
||||
</td>
|
||||
<td><span class="badge" style="background: var(--surface-hover); color: var(--text-primary); border-color: var(--border);">{{.MediaType}}</span></td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td>{{formatBytes .OriginalSize}}</td>
|
||||
<td>{{formatBytes .EncodedSize}}</td>
|
||||
<td style="color: var(--accent);">{{formatBytes .SizeSaved}}</td>
|
||||
<td>{{.ProcessingTime}}s</td>
|
||||
<td>{{.CreatedAt.Format "Jan 02, 15:04:05"}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary);">No job reports found.</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterHistory() {
|
||||
var input, filter, table, tr, td, i, txtValue;
|
||||
input = document.getElementById("history-search");
|
||||
filter = input.value.toUpperCase();
|
||||
table = document.querySelector(".table-container table");
|
||||
tr = table.getElementsByTagName("tr");
|
||||
var visibleCount = 0;
|
||||
|
||||
for (i = 1; i < tr.length; i++) { // Skip header row
|
||||
// If it's the "No job reports found" row, skip it
|
||||
if (tr[i].cells.length === 1) continue;
|
||||
|
||||
td = tr[i].getElementsByTagName("td")[0]; // File column
|
||||
if (td) {
|
||||
txtValue = td.textContent || td.innerText;
|
||||
if (txtValue.toUpperCase().indexOf(filter) > -1) {
|
||||
tr[i].style.display = "";
|
||||
visibleCount++;
|
||||
} else {
|
||||
tr[i].style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the record count
|
||||
document.getElementById("record-count").textContent = filter ? visibleCount : "{{.Total}}";
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{define "layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoEncode</title>
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script>
|
||||
// Global SSE listener setup early so components can attach to it
|
||||
const sse = new EventSource('/api/sse');
|
||||
window.sse = sse;
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.sse) {
|
||||
window.sse.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="navbar-brand" style="display: flex; align-items: center;">
|
||||
<img src="/static/icon.png" alt="" style="height: 1.5rem; margin-right: 0.5rem; border-radius: 4px;">
|
||||
Go<span>Encode</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" id="nav-dashboard">Dashboard</a>
|
||||
<a href="/folders" id="nav-folders">Watch Folders</a>
|
||||
<a href="/history" id="nav-history">History</a>
|
||||
<a href="/logs" id="nav-logs">Logs</a>
|
||||
{{if .AuthEnabled}}
|
||||
<a href="/logout" style="margin-left: 2rem; color: #ff4444; border-color: #ff4444;">Sign Out</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set active nav link
|
||||
const path = window.location.pathname;
|
||||
if (path === "/") document.getElementById("nav-dashboard").classList.add("active");
|
||||
else if (path.startsWith("/folders")) document.getElementById("nav-folders").classList.add("active");
|
||||
else if (path.startsWith("/history")) document.getElementById("nav-history").classList.add("active");
|
||||
else if (path.startsWith("/logs")) document.getElementById("nav-logs").classList.add("active");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoEncode - Login</title>
|
||||
<link rel="icon" type="image/png" href="/static/icon.png">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #141414;
|
||||
--border: #2a2a2a;
|
||||
--text-primary: #f0f0f0;
|
||||
--text-secondary: #888888;
|
||||
--accent: #21D198;
|
||||
--accent-hover: #1cb582;
|
||||
--error: #ef4444;
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border: 1px solid var(--accent);
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-top: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
box-shadow: 0 0 10px rgba(33, 209, 152, 0.3);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 2rem;">
|
||||
<img src="/static/icon.png" alt="" style="height: 2.5rem; margin-right: 1rem; border-radius: 4px;" onerror="this.style.display='none'">
|
||||
<h1 style="margin-bottom: 0;">Go<span>Encode</span></h1>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" name="username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
{{define "content"}}
|
||||
<div class="header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||
<div>
|
||||
<h2 style="margin: 0;">System Logs</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 0.5rem;">Live streaming output of the application logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0;">
|
||||
<pre id="log-container" style="margin: 0; padding: 1.5rem; background: var(--surface); color: var(--text-primary); border-radius: 12px; font-family: monospace; height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.5;"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const logContainer = document.getElementById('log-container');
|
||||
let autoScroll = true;
|
||||
|
||||
logContainer.addEventListener('scroll', () => {
|
||||
const isAtBottom = logContainer.scrollHeight - logContainer.scrollTop <= logContainer.clientHeight + 50;
|
||||
autoScroll = isAtBottom;
|
||||
});
|
||||
|
||||
function appendLog(timestamp, message) {
|
||||
const entry = document.createElement('div');
|
||||
const tsSpan = document.createElement('span');
|
||||
tsSpan.style.color = '#888';
|
||||
tsSpan.textContent = `[${timestamp}] `;
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = message;
|
||||
|
||||
entry.appendChild(tsSpan);
|
||||
entry.appendChild(msgSpan);
|
||||
logContainer.appendChild(entry);
|
||||
|
||||
if (autoScroll) {
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent logs first
|
||||
fetch('/api/logs')
|
||||
.then(response => response.json())
|
||||
.then(logs => {
|
||||
if (logs && logs.length > 0) {
|
||||
logs.forEach(log => {
|
||||
appendLog(log.timestamp, log.message);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching logs:', error));
|
||||
|
||||
// Listen for live updates
|
||||
if (window.sse) {
|
||||
window.sse.addEventListener('log', function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
appendLog(data.timestamp, data.message);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "content"}}
|
||||
<div class="card">
|
||||
<h2>Global Settings</h2>
|
||||
<p style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 2rem;">
|
||||
Database and core server configurations are managed via <code>goencode.yaml</code>.
|
||||
Application preferences can be configured below.
|
||||
</p>
|
||||
|
||||
<form onsubmit="event.preventDefault(); alert('Settings saved');">
|
||||
<div class="form-group" style="max-width: 400px;">
|
||||
<label>Default Video CRF (Quality)</label>
|
||||
<input type="number" class="form-control" value="22" min="0" max="51">
|
||||
</div>
|
||||
<div class="form-group" style="max-width: 400px;">
|
||||
<label>Default Audio Bitrate (kbps)</label>
|
||||
<input type="number" class="form-control" value="320" step="32" min="64" max="320">
|
||||
</div>
|
||||
<button type="submit" class="btn">Save Preferences</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user