Refactor web build process and update documentation

- Removed Node.js build artifacts from .gitignore and adjusted Makefile to reflect changes in web UI build process, now using server-rendered Go templates instead of React.
- Updated README to clarify the new web UI architecture and output formats, emphasizing the removal of the Node.js build step.
- Added a command to set the number of frames per render task in manager configuration, enhancing user control over rendering settings.
- Improved Gitea workflow by removing unnecessary npm install step, streamlining the CI process.
This commit is contained in:
2026-03-12 19:44:40 -05:00
parent d3c5ee0dba
commit 2deb47e5ad
78 changed files with 3895 additions and 12499 deletions

95
web/static/admin.js Normal file
View File

@@ -0,0 +1,95 @@
(function () {
const msgEl = document.getElementById("admin-message");
const errEl = document.getElementById("admin-error");
const saveRegBtn = document.getElementById("save-registration");
const regCheckbox = document.getElementById("registration-enabled");
const createKeyBtn = document.getElementById("create-api-key");
function showMessage(msg) {
msgEl.textContent = msg || "";
msgEl.classList.toggle("hidden", !msg);
}
function showError(msg) {
errEl.textContent = msg || "";
errEl.classList.toggle("hidden", !msg);
}
async function request(url, method, payload) {
const res = await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
function refreshAll() {
if (!window.htmx) return window.location.reload();
htmx.ajax("GET", "/ui/fragments/admin/runners", "#admin-runners");
htmx.ajax("GET", "/ui/fragments/admin/users", "#admin-users");
htmx.ajax("GET", "/ui/fragments/admin/apikeys", "#admin-apikeys");
}
if (saveRegBtn && regCheckbox) {
saveRegBtn.addEventListener("click", async () => {
showError("");
try {
await request("/api/admin/settings/registration", "POST", { enabled: regCheckbox.checked });
showMessage("Registration setting saved.");
} catch (err) {
showError(err.message);
}
});
}
if (createKeyBtn) {
createKeyBtn.addEventListener("click", async () => {
const name = prompt("API key name:");
if (!name) return;
showError("");
try {
const data = await request("/api/admin/runners/api-keys", "POST", { name, scope: "manager" });
showMessage(`New API key created: ${data.key}`);
refreshAll();
} catch (err) {
showError(err.message);
}
});
}
document.body.addEventListener("click", async (e) => {
const deleteRunner = e.target.closest("[data-delete-runner]");
const setAdmin = e.target.closest("[data-set-admin]");
const revokeKey = e.target.closest("[data-revoke-apikey]");
const deleteKey = e.target.closest("[data-delete-apikey]");
if (!deleteRunner && !setAdmin && !revokeKey && !deleteKey) return;
showError("");
try {
if (deleteRunner) {
const id = deleteRunner.getAttribute("data-delete-runner");
if (!confirm("Delete this runner?")) return;
await request(`/api/admin/runners/${id}`, "DELETE");
}
if (setAdmin) {
const id = setAdmin.getAttribute("data-set-admin");
const value = setAdmin.getAttribute("data-admin-value") === "true";
await request(`/api/admin/users/${id}/admin`, "POST", { is_admin: value });
}
if (revokeKey) {
const id = revokeKey.getAttribute("data-revoke-apikey");
await request(`/api/admin/runners/api-keys/${id}/revoke`, "PATCH");
}
if (deleteKey) {
const id = deleteKey.getAttribute("data-delete-apikey");
await request(`/api/admin/runners/api-keys/${id}`, "DELETE");
}
refreshAll();
} catch (err) {
showError(err.message);
}
});
})();

286
web/static/job_new.js Normal file
View File

@@ -0,0 +1,286 @@
(function () {
const uploadForm = document.getElementById("upload-analyze-form");
const configForm = document.getElementById("job-config-form");
const fileInput = document.getElementById("source-file");
const statusEl = document.getElementById("upload-status");
const errorEl = document.getElementById("job-create-error");
const blendVersionEl = document.getElementById("blender-version");
const mainBlendWrapper = document.getElementById("main-blend-wrapper");
const mainBlendSelect = document.getElementById("main-blend-select");
const metadataPreview = document.getElementById("metadata-preview");
const configSection = document.getElementById("job-config-section");
const uploadSection = document.getElementById("job-upload-section");
const uploadSubmitBtn = uploadForm.querySelector('button[type="submit"]');
const stepUpload = document.getElementById("step-upload");
const stepConfig = document.getElementById("step-config");
const nameInput = document.getElementById("job-name");
const frameStartInput = document.getElementById("frame-start");
const frameEndInput = document.getElementById("frame-end");
const outputFormatInput = document.getElementById("output-format");
const unhideObjectsInput = document.getElementById("unhide-objects");
const enableExecutionInput = document.getElementById("enable-execution");
let sessionID = "";
let pollTimer = null;
let uploadInProgress = false;
function showError(msg) {
errorEl.textContent = msg || "";
errorEl.classList.toggle("hidden", !msg);
}
function showStatus(msg) {
statusEl.classList.remove("hidden");
statusEl.innerHTML = `<p>${msg}</p>`;
}
function setUploadBusy(busy) {
uploadInProgress = busy;
if (!uploadSubmitBtn) return;
uploadSubmitBtn.disabled = busy;
}
function setStep(step) {
const uploadActive = step === 1;
stepUpload.classList.toggle("active", uploadActive);
stepUpload.classList.toggle("complete", !uploadActive);
stepConfig.classList.toggle("active", !uploadActive);
uploadSection.classList.toggle("hidden", !uploadActive);
configSection.classList.toggle("hidden", uploadActive);
}
function fileNameToJobName(fileName) {
const stem = (fileName || "Render Job").replace(/\.[^/.]+$/, "");
return stem.trim() || "Render Job";
}
function prefillFromMetadata(status, fileName) {
const metadata = status.metadata || {};
const render = metadata.render_settings || {};
nameInput.value = fileNameToJobName(fileName || status.file_name);
frameStartInput.value = Number.isFinite(metadata.frame_start) ? metadata.frame_start : 1;
frameEndInput.value = Number.isFinite(metadata.frame_end) ? metadata.frame_end : 250;
if (render.output_format && outputFormatInput.querySelector(`option[value="${render.output_format}"]`)) {
outputFormatInput.value = render.output_format;
} else {
outputFormatInput.value = "EXR";
}
if (metadata.blender_version && blendVersionEl.querySelector(`option[value="${metadata.blender_version}"]`)) {
blendVersionEl.value = metadata.blender_version;
} else {
blendVersionEl.value = "";
}
unhideObjectsInput.checked = Boolean(metadata.unhide_objects);
enableExecutionInput.checked = Boolean(metadata.enable_execution);
const scenes = metadata.scene_info || {};
metadataPreview.innerHTML = `
<div class="metadata-grid">
<div><strong>Detected file:</strong> ${status.file_name || fileName || "-"}</div>
<div><strong>Frames:</strong> ${metadata.frame_start ?? "-"} - ${metadata.frame_end ?? "-"}</div>
<div><strong>Render engine:</strong> ${render.engine || "-"}</div>
<div><strong>Resolution:</strong> ${render.resolution_x || "-"} x ${render.resolution_y || "-"}</div>
<div><strong>Frame rate:</strong> ${render.frame_rate || "-"}</div>
<div><strong>Objects:</strong> ${scenes.object_count ?? "-"}</div>
</div>
`;
}
async function loadBlenderVersions() {
try {
const res = await fetch("/api/blender/versions", { credentials: "include" });
if (!res.ok) return;
const data = await res.json();
const versions = data.versions || [];
versions.slice(0, 30).forEach((v) => {
const option = document.createElement("option");
option.value = v.full;
option.textContent = v.full;
blendVersionEl.appendChild(option);
});
} catch (_) {}
}
function uploadFile(mainBlendFile) {
return new Promise((resolve, reject) => {
const file = fileInput.files[0];
if (!file) {
reject(new Error("Select a file first"));
return;
}
const lowerName = file.name.toLowerCase();
const isAccepted = lowerName.endsWith(".blend") || lowerName.endsWith(".zip");
if (!isAccepted) {
reject(new Error("Only .blend or .zip files are supported."));
return;
}
const fd = new FormData();
fd.append("file", file);
if (mainBlendFile) {
fd.append("main_blend_file", mainBlendFile);
}
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/jobs/upload", true);
xhr.withCredentials = true;
xhr.upload.addEventListener("progress", (e) => {
if (!e.lengthComputable) return;
const pct = Math.round((e.loaded / e.total) * 100);
showStatus(`Uploading: ${pct}%`);
});
xhr.onload = () => {
try {
const data = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 400) {
reject(new Error(data.error || "Upload failed"));
return;
}
resolve(data);
} catch (err) {
reject(err);
}
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.send(fd);
});
}
async function pollUploadStatus() {
if (!sessionID) return null;
const res = await fetch(`/api/jobs/upload/status?session_id=${encodeURIComponent(sessionID)}`, { credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || "Upload status check failed");
}
return data;
}
async function createJob(payload) {
const res = await fetch("/api/jobs", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || "Job creation failed");
}
return data;
}
async function runSubmission(mainBlendFile) {
showError("");
setStep(1);
configSection.classList.add("hidden");
metadataPreview.innerHTML = "";
const upload = await uploadFile(mainBlendFile);
sessionID = upload.session_id;
showStatus("Upload complete. Processing...");
clearInterval(pollTimer);
await new Promise((resolve, reject) => {
pollTimer = setInterval(async () => {
try {
const status = await pollUploadStatus();
if (!status) return;
showStatus(`${status.message || status.status} (${Math.round((status.progress || 0) * 100)}%)`);
if (status.status === "select_blend") {
clearInterval(pollTimer);
mainBlendSelect.innerHTML = "";
(status.blend_files || []).forEach((path) => {
const option = document.createElement("option");
option.value = path;
option.textContent = path;
mainBlendSelect.appendChild(option);
});
mainBlendWrapper.classList.remove("hidden");
reject(new Error("Select a main blend file and submit again."));
return;
}
if (status.status === "error") {
clearInterval(pollTimer);
reject(new Error(status.error || "Upload processing failed"));
return;
}
if (status.status === "completed") {
clearInterval(pollTimer);
prefillFromMetadata(status, fileInput.files[0]?.name || "");
setStep(2);
resolve();
}
} catch (err) {
clearInterval(pollTimer);
reject(err);
}
}, 1500);
});
}
async function submitJobConfig() {
if (!sessionID) {
throw new Error("Upload and analyze a file first.");
}
const fd = new FormData(configForm);
const jobName = String(fd.get("name") || "").trim();
if (!jobName) {
throw new Error("Job name is required.");
}
nameInput.value = jobName;
const payload = {
job_type: "render",
name: jobName,
frame_start: Number(fd.get("frame_start")),
frame_end: Number(fd.get("frame_end")),
output_format: fd.get("output_format"),
upload_session_id: sessionID,
unhide_objects: Boolean(fd.get("unhide_objects")),
enable_execution: Boolean(fd.get("enable_execution")),
};
const blenderVersion = fd.get("blender_version");
if (blenderVersion) payload.blender_version = blenderVersion;
const job = await createJob(payload);
showStatus(`Job created (#${job.id}). Redirecting...`);
window.location.href = `/jobs/${job.id}`;
}
uploadForm.addEventListener("submit", async (e) => {
e.preventDefault();
if (uploadInProgress) {
return;
}
try {
setUploadBusy(true);
const selected = mainBlendWrapper.classList.contains("hidden") ? "" : mainBlendSelect.value;
await runSubmission(selected);
} catch (err) {
showError(err.message || "Failed to create job");
setUploadBusy(false);
}
});
configForm.addEventListener("submit", async (e) => {
e.preventDefault();
try {
showError("");
await submitJobConfig();
} catch (err) {
showError(err.message || "Failed to create job");
}
});
setStep(1);
loadBlenderVersions();
})();

428
web/static/job_show.js Normal file
View File

@@ -0,0 +1,428 @@
(function () {
const jobID = window.location.pathname.split("/").pop();
const progressFill = document.querySelector(".progress-fill[data-progress]");
const progressText = document.getElementById("job-progress-text");
const statusBadge = document.getElementById("job-status-badge");
const tasksRefreshBtn = document.getElementById("tasks-refresh");
const tasksFragment = document.getElementById("tasks-fragment");
const filesRefreshBtn = document.getElementById("files-refresh");
const filesFragment = document.getElementById("files-fragment");
const cancelJobBtn = document.getElementById("cancel-job-btn");
const deleteJobBtn = document.getElementById("delete-job-btn");
const previewModal = document.getElementById("exr-preview-modal");
const previewImage = document.getElementById("exr-preview-image");
const previewLoading = document.getElementById("exr-preview-loading");
const previewError = document.getElementById("exr-preview-error");
const previewName = document.getElementById("exr-preview-name");
let lastJobSnapshot = null;
let lastSmartRefreshAt = 0;
if (progressFill) {
const value = Number(progressFill.getAttribute("data-progress") || "0");
const bounded = Math.max(0, Math.min(100, value));
progressFill.style.width = `${bounded}%`;
}
function statusClass(status) {
const normalized = String(status || "").toLowerCase();
if (normalized === "completed") return "status-completed";
if (normalized === "running") return "status-running";
if (normalized === "failed") return "status-failed";
if (normalized === "cancelled") return "status-cancelled";
return "status-pending";
}
function applyJobState(job) {
if (!job) return;
const normalizedStatus = String(job.status || "pending").toLowerCase();
const canCancel = normalizedStatus === "pending" || normalizedStatus === "running";
const canDelete = normalizedStatus === "completed" || normalizedStatus === "failed" || normalizedStatus === "cancelled";
const progressValue = Math.max(0, Math.min(100, Number(job.progress || 0)));
if (progressFill) {
progressFill.style.width = `${progressValue}%`;
progressFill.setAttribute("data-progress", String(Math.round(progressValue)));
}
if (progressText) {
progressText.textContent = `${Math.round(progressValue)}%`;
}
if (statusBadge) {
statusBadge.textContent = normalizedStatus;
statusBadge.classList.remove("status-pending", "status-running", "status-completed", "status-failed", "status-cancelled");
statusBadge.classList.add(statusClass(job.status));
}
if (cancelJobBtn) {
cancelJobBtn.classList.toggle("hidden", !canCancel);
}
if (deleteJobBtn) {
deleteJobBtn.classList.toggle("hidden", !canDelete);
}
}
function refreshTasksAndFiles() {
if (!window.htmx) return;
if (tasksFragment) {
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/tasks`, "#tasks-fragment");
}
if (filesFragment) {
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/files`, "#files-fragment");
}
lastSmartRefreshAt = Date.now();
}
async function pollJobState() {
try {
const res = await fetch(`/api/jobs/${jobID}`, { credentials: "include" });
if (!res.ok) return;
const job = await res.json();
applyJobState(job);
const snapshot = {
status: String(job.status || ""),
progress: Math.round(Number(job.progress || 0)),
startedAt: job.started_at || "",
completedAt: job.completed_at || "",
};
const changed =
!lastJobSnapshot ||
snapshot.status !== lastJobSnapshot.status ||
snapshot.progress !== lastJobSnapshot.progress ||
snapshot.startedAt !== lastJobSnapshot.startedAt ||
snapshot.completedAt !== lastJobSnapshot.completedAt;
lastJobSnapshot = snapshot;
// Smart refresh fragments only when job state changes.
if (changed) {
refreshTasksAndFiles();
return;
}
// Fallback while running: refresh infrequently even without visible progress deltas.
if (snapshot.status === "running" && Date.now() - lastSmartRefreshAt > 12000) {
refreshTasksAndFiles();
}
} catch (_) {
// Keep UI usable even if polling briefly fails.
}
}
if (tasksRefreshBtn && tasksFragment && window.htmx) {
tasksRefreshBtn.addEventListener("click", () => {
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/tasks`, "#tasks-fragment");
});
}
if (filesRefreshBtn && filesFragment && window.htmx) {
filesRefreshBtn.addEventListener("click", () => {
htmx.ajax("GET", `/ui/fragments/jobs/${jobID}/files`, "#files-fragment");
});
}
pollJobState();
setInterval(pollJobState, 2500);
async function apiRequest(url, method) {
const res = await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || "Request failed");
}
return data;
}
function closePreviewModal() {
if (!previewModal) return;
previewModal.classList.add("hidden");
if (previewImage) {
previewImage.classList.add("hidden");
previewImage.removeAttribute("src");
}
if (previewLoading) previewLoading.classList.remove("hidden");
if (previewError) {
previewError.classList.add("hidden");
previewError.textContent = "";
}
}
function openPreviewModal(url, name) {
if (!previewModal || !previewImage) return;
previewModal.classList.remove("hidden");
if (previewName) previewName.textContent = name ? `File: ${name}` : "";
if (previewLoading) previewLoading.classList.remove("hidden");
if (previewError) {
previewError.classList.add("hidden");
previewError.textContent = "";
}
previewImage.classList.add("hidden");
previewImage.onload = () => {
if (previewLoading) previewLoading.classList.add("hidden");
previewImage.classList.remove("hidden");
};
previewImage.onerror = () => {
if (previewLoading) previewLoading.classList.add("hidden");
if (previewError) {
previewError.textContent = "Failed to load preview image.";
previewError.classList.remove("hidden");
}
};
previewImage.src = url;
}
document.body.addEventListener("click", async (e) => {
const previewBtn = e.target.closest("[data-exr-preview-url]");
if (previewBtn) {
const url = previewBtn.getAttribute("data-exr-preview-url");
const name = previewBtn.getAttribute("data-exr-preview-name");
if (url) {
openPreviewModal(url, name || "");
}
return;
}
const modalClose = e.target.closest("[data-modal-close]");
if (modalClose) {
closePreviewModal();
return;
}
const cancelBtn = e.target.closest("[data-cancel-job]");
const deleteBtn = e.target.closest("[data-delete-job]");
if (!cancelBtn && !deleteBtn) return;
const id = (cancelBtn || deleteBtn).getAttribute(cancelBtn ? "data-cancel-job" : "data-delete-job");
try {
if (cancelBtn) {
if (!confirm("Cancel this job?")) return;
await apiRequest(`/api/jobs/${id}`, "DELETE");
} else {
if (!confirm("Delete this job permanently?")) return;
await apiRequest(`/api/jobs/${id}/delete`, "POST");
window.location.href = "/jobs";
return;
}
window.location.reload();
} catch (err) {
alert(err.message);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closePreviewModal();
}
});
const taskSelect = document.getElementById("task-log-task-id");
const levelFilter = document.getElementById("task-log-level-filter");
const autoRefreshToggle = document.getElementById("task-log-auto-refresh");
const followToggle = document.getElementById("task-log-follow");
const refreshBtn = document.getElementById("task-log-refresh");
const copyBtn = document.getElementById("task-log-copy");
const output = document.getElementById("task-log-output");
const statusEl = document.getElementById("task-log-status");
const state = {
timer: null,
activeTaskID: "",
lastLogID: 0,
logs: [],
seenIDs: new Set(),
};
function setStatus(text) {
if (statusEl) statusEl.textContent = text;
}
function levelClass(level) {
const normalized = String(level || "INFO").toUpperCase();
if (normalized === "ERROR") return "log-error";
if (normalized === "WARN") return "log-warn";
if (normalized === "DEBUG") return "log-debug";
return "log-info";
}
function formatTime(ts) {
if (!ts) return "--:--:--";
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return "--:--:--";
return d.toLocaleTimeString();
}
function renderLogs() {
if (!output) return;
const selectedLevel = (levelFilter?.value || "").toUpperCase();
const filtered = state.logs.filter((entry) => {
if (!selectedLevel) return true;
return String(entry.log_level || "").toUpperCase() === selectedLevel;
});
if (filtered.length === 0) {
output.innerHTML = '<div class="log-line empty">No logs yet.</div>';
return;
}
output.innerHTML = filtered.map((entry) => {
const level = String(entry.log_level || "INFO").toUpperCase();
const step = entry.step_name ? ` <span class="log-step">(${entry.step_name})</span>` : "";
const message = String(entry.message || "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
return `<div class="log-line">
<span class="log-time">${formatTime(entry.created_at)}</span>
<span class="log-level ${levelClass(level)}">${level}</span>${step}
<span class="log-message">${message}</span>
</div>`;
}).join("");
if (followToggle?.checked) {
output.scrollTop = output.scrollHeight;
}
}
function getVisibleLogs() {
const selectedLevel = (levelFilter?.value || "").toUpperCase();
return state.logs.filter((entry) => {
if (!selectedLevel) return true;
return String(entry.log_level || "").toUpperCase() === selectedLevel;
});
}
function logsToText(entries) {
return entries.map((entry) => {
const level = String(entry.log_level || "INFO").toUpperCase();
const step = entry.step_name ? ` (${entry.step_name})` : "";
return `[${formatTime(entry.created_at)}] [${level}]${step} ${entry.message || ""}`;
}).join("\n");
}
function collectTaskOptions() {
if (!taskSelect) return;
const buttons = document.querySelectorAll("[data-view-logs-task-id]");
const current = taskSelect.value;
taskSelect.innerHTML = '<option value="">Choose a task...</option>';
buttons.forEach((btn) => {
const id = btn.getAttribute("data-view-logs-task-id");
if (!id) return;
const row = btn.closest("tr");
const status = row?.querySelector(".status")?.textContent?.trim() || "";
const type = row?.children?.[1]?.textContent?.trim() || "";
const option = document.createElement("option");
option.value = id;
option.textContent = `#${id} ${type ? `(${type})` : ""} ${status ? `- ${status}` : ""}`.trim();
taskSelect.appendChild(option);
});
if (current && taskSelect.querySelector(`option[value="${current}"]`)) {
taskSelect.value = current;
}
}
async function fetchLogs({ reset = false, full = false } = {}) {
const taskID = taskSelect?.value?.trim();
if (!taskID) {
setStatus("Select a task to view logs.");
return;
}
if (reset || taskID !== state.activeTaskID) {
state.activeTaskID = taskID;
state.lastLogID = 0;
state.logs = [];
state.seenIDs.clear();
renderLogs();
}
const params = new URLSearchParams();
params.set("limit", "0"); // backend: 0 = no limit
if (!full && state.lastLogID > 0) {
params.set("since_id", String(state.lastLogID));
}
try {
const res = await fetch(`/api/jobs/${jobID}/tasks/${taskID}/logs?${params.toString()}`, {
credentials: "include",
});
if (!res.ok) {
setStatus(`Failed to fetch logs (HTTP ${res.status}).`);
return;
}
const payload = await res.json();
const rows = Array.isArray(payload) ? payload : (payload.logs || []);
if (rows.length > 0) {
for (const row of rows) {
const id = Number(row.id || 0);
if (id > 0 && !state.seenIDs.has(id)) {
state.seenIDs.add(id);
state.logs.push(row);
if (id > state.lastLogID) state.lastLogID = id;
}
}
if (!Array.isArray(payload) && Number(payload.last_id || 0) > state.lastLogID) {
state.lastLogID = Number(payload.last_id);
}
}
setStatus(`Task #${taskID}: ${state.logs.length} log line(s).`);
renderLogs();
} catch (err) {
setStatus(`Failed to fetch logs: ${err.message}`);
}
}
function restartPolling() {
if (state.timer) {
clearInterval(state.timer);
state.timer = null;
}
if (!autoRefreshToggle?.checked) return;
state.timer = setInterval(() => {
if (taskSelect?.value) {
fetchLogs();
}
}, 2000);
}
if (tasksFragment) {
tasksFragment.addEventListener("htmx:afterSwap", () => {
collectTaskOptions();
});
}
collectTaskOptions();
document.body.addEventListener("click", (e) => {
const viewBtn = e.target.closest("[data-view-logs-task-id]");
if (!viewBtn || !taskSelect) return;
const taskID = viewBtn.getAttribute("data-view-logs-task-id");
if (!taskID) return;
taskSelect.value = taskID;
fetchLogs({ reset: true, full: true });
});
if (taskSelect) {
taskSelect.addEventListener("change", () => fetchLogs({ reset: true, full: true }));
}
if (levelFilter) {
levelFilter.addEventListener("change", renderLogs);
}
if (refreshBtn) {
refreshBtn.addEventListener("click", () => fetchLogs({ reset: true, full: true }));
}
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const visible = getVisibleLogs();
if (visible.length === 0) {
setStatus("No logs to copy.");
return;
}
try {
await navigator.clipboard.writeText(logsToText(visible));
setStatus(`Copied ${visible.length} log line(s).`);
} catch (_) {
setStatus("Clipboard copy failed.");
}
});
}
if (autoRefreshToggle) {
autoRefreshToggle.addEventListener("change", restartPolling);
}
restartPolling();
})();

41
web/static/jobs.js Normal file
View File

@@ -0,0 +1,41 @@
(function () {
async function apiRequest(url, method) {
const res = await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || "Request failed");
}
return data;
}
document.body.addEventListener("click", async (e) => {
const cancelBtn = e.target.closest("[data-cancel-job]");
const deleteBtn = e.target.closest("[data-delete-job]");
if (!cancelBtn && !deleteBtn) return;
try {
if (cancelBtn) {
const id = cancelBtn.getAttribute("data-cancel-job");
if (!confirm("Cancel this job?")) return;
await apiRequest(`/api/jobs/${id}`, "DELETE");
}
if (deleteBtn) {
const id = deleteBtn.getAttribute("data-delete-job");
if (!confirm("Delete this job permanently?")) return;
await apiRequest(`/api/jobs/${id}/delete`, "POST");
}
if (window.htmx) {
htmx.trigger("#jobs-fragment", "refresh");
htmx.ajax("GET", "/ui/fragments/jobs", "#jobs-fragment");
} else {
window.location.reload();
}
} catch (err) {
alert(err.message);
}
});
})();

65
web/static/login.js Normal file
View File

@@ -0,0 +1,65 @@
(function () {
const loginForm = document.getElementById("login-form");
const registerForm = document.getElementById("register-form");
const errorEl = document.getElementById("auth-error");
function setError(msg) {
if (!errorEl) return;
if (!msg) {
errorEl.classList.add("hidden");
errorEl.textContent = "";
return;
}
errorEl.textContent = msg;
errorEl.classList.remove("hidden");
}
async function postJSON(url, payload) {
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || "Request failed");
}
return body;
}
if (loginForm) {
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
setError("");
const fd = new FormData(loginForm);
try {
await postJSON("/api/auth/local/login", {
username: fd.get("username"),
password: fd.get("password"),
});
window.location.href = "/jobs";
} catch (err) {
setError(err.message);
}
});
}
if (registerForm) {
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
setError("");
const fd = new FormData(registerForm);
try {
await postJSON("/api/auth/local/register", {
name: fd.get("name"),
email: fd.get("email"),
password: fd.get("password"),
});
window.location.href = "/jobs";
} catch (err) {
setError(err.message);
}
});
}
})();

241
web/static/style.css Normal file
View File

@@ -0,0 +1,241 @@
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
}
.container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid #334155;
background: #111827;
}
.brand { font-weight: 700; }
.nav { display: flex; gap: 12px; }
.nav a { color: #cbd5e1; text-decoration: none; padding: 8px 10px; border-radius: 6px; }
.nav a.active, .nav a:hover { background: #1f2937; color: #fff; }
.account { display: flex; gap: 12px; align-items: center; }
.card {
background: #111827;
border: 1px solid #334155;
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
}
.card.narrow { max-width: 900px; margin-inline: auto; }
.section-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.btn {
border: 1px solid #475569;
color: #e2e8f0;
background: #1f2937;
border-radius: 7px;
padding: 8px 12px;
cursor: pointer;
text-decoration: none;
}
.btn:hover { background: #334155; }
.btn.primary { background: #2563eb; border-color: #2563eb; color: white; }
.btn:disabled,
.btn[disabled] {
cursor: not-allowed;
opacity: 1;
}
.btn.primary:disabled,
.btn.primary[disabled] {
background: #1e293b;
border-color: #475569;
color: #94a3b8;
}
.btn.danger { background: #b91c1c; border-color: #b91c1c; color: white; }
.btn.subtle { background: transparent; }
.btn.tiny { padding: 4px 8px; font-size: 12px; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border-bottom: 1px solid #334155; padding: 8px; text-align: left; vertical-align: top; }
.table th { font-size: 12px; text-transform: uppercase; color: #94a3b8; }
.job-link,
.job-link:visited,
.job-link:hover,
.job-link:active {
color: #93c5fd;
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
cursor: pointer;
}
.job-link:hover,
.job-link:focus-visible {
color: #bfdbfe;
text-decoration-thickness: 2px;
}
.status { border-radius: 999px; padding: 2px 8px; font-size: 12px; }
.status-pending { background: #7c2d12; color: #fdba74; }
.status-running { background: #164e63; color: #67e8f9; }
.status-completed { background: #14532d; color: #86efac; }
.status-failed { background: #7f1d1d; color: #fca5a5; }
.status-cancelled { background: #334155; color: #cbd5e1; }
.status-online { background: #14532d; color: #86efac; }
.status-offline { background: #334155; color: #cbd5e1; }
.status-busy { background: #164e63; color: #67e8f9; }
.progress {
width: 100%;
height: 10px;
background: #1e293b;
border-radius: 999px;
overflow: hidden;
}
.progress-fill { height: 100%; background: #2563eb; }
.alert {
border-radius: 8px;
padding: 10px 12px;
margin: 10px 0;
}
.alert.error { background: #7f1d1d; color: #fee2e2; border: 1px solid #ef4444; }
.alert.notice { background: #1e3a8a; color: #dbeafe; border: 1px solid #3b82f6; }
label { display: block; }
input, select {
width: 100%;
margin-top: 6px;
margin-bottom: 12px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
padding: 8px;
}
.stack { display: grid; gap: 8px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
.auth-grid { display: flex; gap: 10px; margin-bottom: 12px; }
.check-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.stepper { display: flex; gap: 10px; margin-bottom: 12px; }
.step {
border: 1px solid #334155;
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
color: #94a3b8;
}
.step.active {
border-color: #2563eb;
color: #bfdbfe;
background: #1e3a8a;
}
.step.complete {
border-color: #14532d;
color: #86efac;
background: #052e16;
}
.muted { color: #94a3b8; margin-top: 0; }
.metadata-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 8px 0 12px;
}
.logs {
max-height: 320px;
overflow: auto;
background: #020617;
border: 1px solid #334155;
border-radius: 8px;
padding: 10px;
white-space: pre-wrap;
}
.log-controls {
display: grid;
grid-template-columns: 2fr 1fr auto auto auto auto;
gap: 10px;
align-items: end;
margin-bottom: 10px;
}
.log-toggle {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 12px;
white-space: nowrap;
}
.log-toggle input {
width: auto;
margin: 0;
}
.log-lines {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: normal;
}
.log-line {
display: grid;
grid-template-columns: auto auto auto 1fr;
gap: 8px;
align-items: start;
padding: 4px 0;
border-bottom: 1px solid #1e293b;
}
.log-line.empty {
display: block;
color: #94a3b8;
border-bottom: none;
}
.log-time { color: #64748b; }
.log-level {
border-radius: 999px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
.log-info { background: #164e63; color: #67e8f9; }
.log-warn { background: #7c2d12; color: #fdba74; }
.log-error { background: #7f1d1d; color: #fca5a5; }
.log-debug { background: #334155; color: #cbd5e1; }
.log-step { color: #93c5fd; }
.log-message {
color: #e2e8f0;
overflow-wrap: anywhere;
}
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.8);
}
.modal-content {
position: relative;
width: min(1100px, 94vw);
max-height: 90vh;
overflow: auto;
background: #0b1220;
border: 1px solid #334155;
border-radius: 10px;
padding: 12px;
}
.modal-body {
min-height: 220px;
}
.preview-image {
display: block;
max-width: 100%;
max-height: 70vh;
margin: 0 auto;
border: 1px solid #334155;
border-radius: 8px;
}
.hidden { display: none; }