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

49
web/templates/admin.html Normal file
View File

@@ -0,0 +1,49 @@
{{ define "page_admin" }}
{{ $view := .Data }}
<section class="card">
<h1>Admin Panel</h1>
<div class="check-row">
<label>
<input id="registration-enabled" type="checkbox" {{ if index $view "registration_enabled" }}checked{{ end }}>
Allow new registrations
</label>
<button id="save-registration" class="btn">Save</button>
</div>
</section>
<section class="card">
<h2>Runners</h2>
<div id="admin-runners"
hx-get="/ui/fragments/admin/runners"
hx-trigger="load, every 6s"
hx-swap="innerHTML">
<p>Loading runners...</p>
</div>
</section>
<section class="card">
<h2>Users</h2>
<div id="admin-users"
hx-get="/ui/fragments/admin/users"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<p>Loading users...</p>
</div>
</section>
<section class="card">
<div class="section-head">
<h2>Runner API Keys</h2>
<button id="create-api-key" class="btn">Create API Key</button>
</div>
<div id="admin-apikeys"
hx-get="/ui/fragments/admin/apikeys"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<p>Loading API keys...</p>
</div>
</section>
<p id="admin-message" class="alert notice hidden"></p>
<p id="admin-error" class="alert error hidden"></p>
{{ end }}

48
web/templates/base.html Normal file
View File

@@ -0,0 +1,48 @@
{{ define "base" }}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }} - JiggaBlend</title>
<link rel="stylesheet" href="/assets/style.css">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
{{ if .User }}
<header class="topbar">
<div class="brand">JiggaBlend</div>
<nav class="nav">
<a href="/jobs" class="{{ if eq .CurrentPath "/jobs" }}active{{ end }}">Jobs</a>
<a href="/jobs/new" class="{{ if eq .CurrentPath "/jobs/new" }}active{{ end }}">Submit</a>
{{ if .User.IsAdmin }}<a href="/admin" class="{{ if eq .CurrentPath "/admin" }}active{{ end }}">Admin</a>{{ end }}
</nav>
<div class="account">
<span>{{ .User.Name }}</span>
<form method="post" action="/logout">
<button type="submit" class="btn subtle">Logout</button>
</form>
</div>
</header>
{{ end }}
<main class="container">
{{ if .Error }}<div class="alert error">{{ .Error }}</div>{{ end }}
{{ if .Notice }}<div class="alert notice">{{ .Notice }}</div>{{ end }}
{{ if eq .ContentTemplate "page_login" }}
{{ template "page_login" . }}
{{ else if eq .ContentTemplate "page_jobs" }}
{{ template "page_jobs" . }}
{{ else if eq .ContentTemplate "page_jobs_new" }}
{{ template "page_jobs_new" . }}
{{ else if eq .ContentTemplate "page_job_show" }}
{{ template "page_job_show" . }}
{{ else if eq .ContentTemplate "page_admin" }}
{{ template "page_admin" . }}
{{ end }}
</main>
{{ if .PageScript }}<script src="{{ .PageScript }}"></script>{{ end }}
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,60 @@
{{ define "page_jobs_new" }}
<section id="job-upload-section" class="card">
<h1>Create Render Job</h1>
<div class="stepper">
<div id="step-upload" class="step active">1. Upload & Analyze</div>
<div id="step-config" class="step">2. Review & Submit</div>
</div>
<form id="upload-analyze-form" class="stack">
<label>Upload Blend/ZIP
<input type="file" id="source-file" name="file" accept=".blend,.zip,application/zip,application/x-zip-compressed" required>
</label>
<label id="main-blend-wrapper" class="hidden">Main Blend (for ZIP with multiple .blend files)
<select id="main-blend-select"></select>
</label>
<button type="submit" class="btn primary">Upload and Analyze</button>
</form>
<div id="upload-status" class="stack hidden"></div>
<p id="job-create-error" class="alert error hidden"></p>
</section>
<section id="job-config-section" class="card hidden">
<h2>Review Render Settings</h2>
<p class="muted">Values are prefilled from extracted metadata; adjust before submission.</p>
<div id="metadata-preview" class="stack"></div>
<form id="job-config-form" class="stack">
<label>Job Name
<input type="text" id="job-name" name="name" required>
</label>
<div class="grid-2">
<label>Frame Start
<input type="number" id="frame-start" name="frame_start" min="0" required>
</label>
<label>Frame End
<input type="number" id="frame-end" name="frame_end" min="0" required>
</label>
</div>
<label>Output Format
<select name="output_format" id="output-format">
<option value="EXR">EXR</option>
<option value="EXR_264_MP4">EXR + H264 MP4</option>
<option value="EXR_AV1_MP4">EXR + AV1 MP4</option>
<option value="EXR_VP9_WEBM">EXR + VP9 WEBM</option>
</select>
</label>
<label>Blender Version (optional)
<select name="blender_version" id="blender-version">
<option value="">Auto-detect from file</option>
</select>
</label>
<div class="check-row">
<label><input type="checkbox" id="unhide-objects" name="unhide_objects"> Unhide objects/collections</label>
<label><input type="checkbox" id="enable-execution" name="enable_execution"> Enable auto-execution in Blender</label>
</div>
<button type="submit" class="btn primary">Create Job</button>
</form>
</section>
{{ end }}

View File

@@ -0,0 +1,97 @@
{{ define "page_job_show" }}
{{ $view := .Data }}
{{ $job := index $view "job" }}
<section class="card">
<div class="section-head">
<h1>Job #{{ $job.ID }} - {{ $job.Name }}</h1>
<a href="/jobs" class="btn subtle">Back</a>
</div>
<p>Status: <span id="job-status-badge" class="status {{ statusClass $job.Status }}">{{ $job.Status }}</span></p>
<p>Progress: <span id="job-progress-text">{{ progressInt $job.Progress }}%</span></p>
<div class="progress">
<div class="progress-fill" data-progress="{{ progressInt $job.Progress }}"></div>
</div>
<div class="row">
{{ if $job.FrameStart }}<span>Frames: {{ derefInt $job.FrameStart }}{{ if $job.FrameEnd }}-{{ derefInt $job.FrameEnd }}{{ end }}</span>{{ end }}
{{ if $job.OutputFormat }}<span>Format: {{ derefString $job.OutputFormat }}</span>{{ end }}
<span>Created: {{ formatTime $job.CreatedAt }}</span>
</div>
<div class="section-head">
<button id="cancel-job-btn" class="btn{{ if not (or (eq $job.Status "pending") (eq $job.Status "running")) }} hidden{{ end }}" data-cancel-job="{{ $job.ID }}">Cancel Job</button>
<button id="delete-job-btn" class="btn danger{{ if not (or (eq $job.Status "completed") (eq $job.Status "failed") (eq $job.Status "cancelled")) }} hidden{{ end }}" data-delete-job="{{ $job.ID }}">Delete Job</button>
</div>
</section>
<section class="card">
<div class="section-head">
<h2>Tasks</h2>
<button id="tasks-refresh" class="btn tiny">Refresh tasks</button>
</div>
<div id="tasks-fragment"
hx-get="/ui/fragments/jobs/{{ $job.ID }}/tasks"
hx-trigger="load"
hx-swap="innerHTML">
<p>Loading tasks...</p>
</div>
</section>
<section class="card">
<div class="section-head">
<h2>Files</h2>
<div class="row">
<a href="/api/jobs/{{ $job.ID }}/files/exr-zip" class="btn tiny">Download all EXR (.zip)</a>
<button id="files-refresh" class="btn tiny">Refresh files</button>
</div>
</div>
<div id="files-fragment"
hx-get="/ui/fragments/jobs/{{ $job.ID }}/files"
hx-trigger="load"
hx-swap="innerHTML">
<p>Loading files...</p>
</div>
</section>
<div id="exr-preview-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="exr-preview-title">
<div class="modal-backdrop" data-modal-close></div>
<div class="modal-content">
<div class="section-head">
<h3 id="exr-preview-title">EXR Preview</h3>
<button type="button" id="exr-preview-close" class="btn tiny subtle" data-modal-close>Close</button>
</div>
<p id="exr-preview-name" class="muted"></p>
<div class="modal-body">
<img id="exr-preview-image" alt="EXR preview" class="preview-image hidden">
<p id="exr-preview-loading" class="muted">Loading preview...</p>
<p id="exr-preview-error" class="alert error hidden"></p>
</div>
</div>
</div>
<section class="card">
<div class="section-head">
<h2>Task Logs</h2>
<span id="task-log-status" class="muted">Select a task to view logs.</span>
</div>
<div class="log-controls">
<label>Task
<select id="task-log-task-id">
<option value="">Choose a task...</option>
</select>
</label>
<label>Level
<select id="task-log-level-filter">
<option value="">All</option>
<option value="INFO">INFO</option>
<option value="WARN">WARN</option>
<option value="ERROR">ERROR</option>
<option value="DEBUG">DEBUG</option>
</select>
</label>
<label class="log-toggle"><input id="task-log-auto-refresh" type="checkbox" checked> Auto refresh</label>
<label class="log-toggle"><input id="task-log-follow" type="checkbox" checked> Follow tail</label>
<button id="task-log-refresh" class="btn">Refresh now</button>
<button id="task-log-copy" class="btn subtle">Copy logs</button>
</div>
<div id="task-log-output" class="logs log-lines"></div>
</section>
{{ end }}

16
web/templates/jobs.html Normal file
View File

@@ -0,0 +1,16 @@
{{ define "page_jobs" }}
<section class="card">
<div class="section-head">
<h1>Your Jobs</h1>
<a href="/jobs/new" class="btn primary">New Job</a>
</div>
<div
id="jobs-fragment"
hx-get="/ui/fragments/jobs"
hx-trigger="load, every 5s"
hx-swap="innerHTML"
>
<p>Loading jobs...</p>
</div>
</section>
{{ end }}

41
web/templates/login.html Normal file
View File

@@ -0,0 +1,41 @@
{{ define "page_login" }}
<section class="card narrow">
<h1>Sign in to JiggaBlend</h1>
{{ $view := .Data }}
{{ if index $view "error" }}
<div class="alert error">Login error: {{ index $view "error" }}</div>
{{ end }}
<div class="auth-grid">
{{ if index $view "google_enabled" }}
<a class="btn" href="/api/auth/google/login">Continue with Google</a>
{{ end }}
{{ if index $view "discord_enabled" }}
<a class="btn" href="/api/auth/discord/login">Continue with Discord</a>
{{ end }}
</div>
{{ if index $view "local_enabled" }}
<div class="split">
<form id="login-form" class="stack">
<h2>Local Login</h2>
<label>Email or Username<input type="text" name="username" required></label>
<label>Password<input type="password" name="password" required></label>
<button type="submit" class="btn primary">Login</button>
</form>
<form id="register-form" class="stack">
<h2>Register</h2>
<label>Name<input type="text" name="name" required></label>
<label>Email<input type="email" name="email" required></label>
<label>Password<input type="password" name="password" minlength="8" required></label>
<button type="submit" class="btn">Register</button>
</form>
</div>
{{ else }}
<p>Local authentication is disabled.</p>
{{ end }}
<p id="auth-error" class="alert error hidden"></p>
</section>
{{ end }}

View File

@@ -0,0 +1,36 @@
{{ define "partial_admin_apikeys" }}
{{ $keys := index . "keys" }}
{{ if not $keys }}
<p>No API keys generated yet.</p>
{{ else }}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Scope</th>
<th>Prefix</th>
<th>Active</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range $key := $keys }}
<tr>
<td>{{ $key.ID }}</td>
<td>{{ $key.Name }}</td>
<td>{{ $key.Scope }}</td>
<td>{{ $key.Key }}</td>
<td>{{ if $key.IsActive }}yes{{ else }}no{{ end }}</td>
<td>{{ formatTime $key.CreatedAt }}</td>
<td class="row">
<button class="btn tiny" data-revoke-apikey="{{ $key.ID }}">Revoke</button>
<button class="btn tiny danger" data-delete-apikey="{{ $key.ID }}">Delete</button>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,35 @@
{{ define "partial_admin_runners" }}
{{ $runners := index . "runners" }}
{{ if not $runners }}
<p>No runners registered.</p>
{{ else }}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Host</th>
<th>Status</th>
<th>Priority</th>
<th>Heartbeat</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range $runner := $runners }}
<tr>
<td>{{ $runner.ID }}</td>
<td>{{ $runner.Name }}</td>
<td>{{ $runner.Hostname }}</td>
<td><span class="status {{ statusClass $runner.Status }}">{{ $runner.Status }}</span></td>
<td>{{ $runner.Priority }}</td>
<td>{{ formatTime $runner.LastHeartbeat }}</td>
<td class="row">
<button class="btn tiny danger" data-delete-runner="{{ $runner.ID }}">Delete</button>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,44 @@
{{ define "partial_admin_users" }}
{{ $users := index . "users" }}
{{ $currentUserID := index . "current_user_id" }}
{{ if not $users }}
<p>No users found.</p>
{{ else }}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Provider</th>
<th>Admin</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range $user := $users }}
<tr>
<td>{{ $user.ID }}</td>
<td>{{ $user.Name }}</td>
<td>{{ $user.Email }}</td>
<td>{{ if $user.OAuthProvider }}{{ $user.OAuthProvider }}{{ else }}local{{ end }}</td>
<td>{{ if $user.IsAdmin }}yes{{ else }}no{{ end }}</td>
<td>{{ formatTime $user.CreatedAt }}</td>
<td class="row">
{{ if and $user.IsAdmin (eq $user.ID $currentUserID) }}
<button class="btn tiny" disabled title="You cannot revoke your own admin status">
Revoke Admin
</button>
{{ else }}
<button class="btn tiny" data-set-admin="{{ $user.ID }}" data-admin-value="{{ if $user.IsAdmin }}false{{ else }}true{{ end }}">
{{ if $user.IsAdmin }}Revoke Admin{{ else }}Make Admin{{ end }}
</button>
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,82 @@
{{ define "partial_job_files" }}
{{ $jobID := index . "job_id" }}
{{ $files := index . "files" }}
{{ $isAdmin := index . "is_admin" }}
{{ $adminInputFiles := index . "admin_input_files" }}
{{ if not $files }}
<p>No output files found yet.</p>
{{ else }}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
{{ if $isAdmin }}<th>Type</th>{{ end }}
<th>Size</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range $file := $files }}
<tr>
<td>{{ $file.ID }}</td>
<td>{{ $file.FileName }}</td>
{{ if $isAdmin }}<td>{{ $file.FileType }}</td>{{ end }}
<td>{{ $file.FileSize }}</td>
<td>{{ formatTime $file.CreatedAt }}</td>
<td class="row">
<a class="btn tiny" href="/api/jobs/{{ $jobID }}/files/{{ $file.ID }}/download">Download</a>
{{ if hasSuffixFold $file.FileName ".exr" }}
<button
type="button"
class="btn tiny"
data-exr-preview-url="/api/jobs/{{ $jobID }}/files/{{ $file.ID }}/preview-exr"
data-exr-preview-name="{{ $file.FileName }}"
>
Preview
</button>
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ if $isAdmin }}
<details class="admin-context">
<summary>Admin: context/input files</summary>
{{ if not $adminInputFiles }}
<p>No context/input files found.</p>
{{ else }}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Created</th>
<th>Download</th>
</tr>
</thead>
<tbody>
{{ range $file := $adminInputFiles }}
<tr>
<td>{{ $file.ID }}</td>
<td>{{ $file.FileName }}</td>
<td>{{ $file.FileType }}</td>
<td>{{ $file.FileSize }}</td>
<td>{{ formatTime $file.CreatedAt }}</td>
<td>
<a class="btn tiny" href="/api/jobs/{{ $jobID }}/files/{{ $file.ID }}/download">Download</a>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
</details>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,37 @@
{{ define "partial_job_tasks" }}
{{ $tasks := index . "tasks" }}
{{ if not $tasks }}
<p>No tasks yet.</p>
{{ else }}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>Status</th>
<th>Frame(s)</th>
<th>Step</th>
<th>Retries</th>
<th>Error</th>
<th>Logs</th>
</tr>
</thead>
<tbody>
{{ range $task := $tasks }}
<tr>
<td>{{ $task.ID }}</td>
<td>{{ $task.TaskType }}</td>
<td><span class="status {{ statusClass $task.Status }}">{{ $task.Status }}</span></td>
<td>{{ $task.Frame }}{{ if $task.FrameEnd }}-{{ derefInt $task.FrameEnd }}{{ end }}</td>
<td>{{ if $task.CurrentStep }}{{ $task.CurrentStep }}{{ else }}-{{ end }}</td>
<td>{{ $task.RetryCount }}</td>
<td>{{ if $task.Error }}{{ $task.Error }}{{ else }}-{{ end }}</td>
<td>
<button class="btn tiny" data-view-logs-task-id="{{ $task.ID }}">View logs</button>
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,40 @@
{{ define "partial_jobs_table" }}
{{ $jobs := index . "jobs" }}
{{ if not $jobs }}
<p>No jobs yet. Submit one to get started.</p>
{{ else }}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Progress</th>
<th>Frames</th>
<th>Format</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range $job := $jobs }}
<tr>
<td><a class="job-link" href="/jobs/{{ $job.ID }}">{{ $job.Name }}</a></td>
<td><span class="status {{ statusClass $job.Status }}">{{ $job.Status }}</span></td>
<td>{{ progressInt $job.Progress }}%</td>
<td>{{ if $job.FrameStart }}{{ derefInt $job.FrameStart }}{{ end }}{{ if $job.FrameEnd }}-{{ derefInt $job.FrameEnd }}{{ end }}</td>
<td>{{ if $job.OutputFormat }}{{ derefString $job.OutputFormat }}{{ else }}-{{ end }}</td>
<td>{{ formatTime $job.CreatedAt }}</td>
<td class="row">
{{ if or (eq $job.Status "pending") (eq $job.Status "running") }}
<button class="btn tiny" data-cancel-job="{{ $job.ID }}">Cancel</button>
{{ end }}
{{ if or (eq $job.Status "completed") (eq $job.Status "failed") (eq $job.Status "cancelled") }}
<button class="btn tiny danger" data-delete-job="{{ $job.ID }}">Delete</button>
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ end }}