otb-cloud secure encrypted backups
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

395 lines
14 KiB

{% extends "portal_base.html" %}
{% block title %}Video Workshop - OTB Cloud{% endblock %}
{% block portal_content %}
<style>
#rotationSelect {
background: #1e293b;
color: #e5e7eb;
border: 1px solid rgba(255,255,255,0.18);
}
#rotationSelect option {
background: #1e293b;
color: #e5e7eb;
}
.workshop-wrap { max-width: 1100px; margin: 0 auto; }
.job-list { display:flex; flex-direction:column; gap:14px; margin-top:14px; }
.job-card { background:rgba(255,255,255,0.04); border:1px solid rgba(255,255,255,0.08); border-radius:14px; padding:14px; }
.job-head { display:flex; justify-content:space-between; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:8px; }
.job-file { font-weight:700; word-break:break-word; }
.job-head-right { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.job-badge { display:inline-block; padding:4px 10px; border-radius:999px; font-size:0.85rem; font-weight:700; border:1px solid rgba(255,255,255,0.14); }
.job-badge.queued { background:rgba(245,158,11,0.18); }
.job-badge.processing { background:rgba(59,130,246,0.18); }
.job-badge.complete { background:rgba(34,197,94,0.18); }
.job-badge.failed { background:rgba(239,68,68,0.18); }
.job-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; margin-top:8px; }
.job-meta { background:rgba(255,255,255,0.03); border-radius:10px; padding:10px; }
.job-label { font-size:0.8rem; opacity:0.75; margin-bottom:4px; }
.job-value { word-break:break-word; }
.job-path, .job-error { margin-top:10px; padding:10px; border-radius:10px; background:rgba(255,255,255,0.03); word-break:break-word; }
.job-error { border:1px solid rgba(239,68,68,0.22); }
.job-empty { padding:16px; border-radius:12px; background:rgba(255,255,255,0.04); opacity:0.85; }
.actions-row { display:flex; gap:10px; flex-wrap:wrap; }
.profile-checks { display:flex; gap:16px; flex-wrap:wrap; margin-top:8px; }
.rotation-wrap { margin-top:12px; }
.selected-box {
background:rgba(255,255,255,0.04);
padding:12px;
border-radius:12px;
overflow:auto;
min-height:80px;
}
.staged-list {
display:flex;
flex-direction:column;
gap:8px;
}
.staged-row {
display:flex;
align-items:center;
gap:10px;
padding:8px 10px;
border-radius:10px;
background:rgba(255,255,255,0.03);
}
.staged-name {
word-break:break-word;
}
</style>
<div class="workshop-wrap">
<div class="portal-page-header">
<div>
<h1 class="portal-page-title">Video Workshop</h1>
<p class="portal-page-subtitle">Device ID: <strong>{{ device_id }}</strong></p>
</div>
<div class="portal-toolbar" style="display:flex;gap:10px;flex-wrap:wrap;">
<a class="portal-btn" href="/devices/{{ device_id }}/files">Back to Device Files</a>
<a class="portal-btn" href="/health">Health</a>
<a class="portal-btn" href="/dashboard">Back to Dashboard</a>
</div>
</div>
<div class="service-card" style="margin-top:18px;">
<div class="service-card-header">
<div>
<h2>Queue Video Jobs</h2>
<p>Selected files from the device browser are staged here. Only checked staged files will be processed.</p>
</div>
<div>
<span class="service-badge service-badge-beta">alpha3-l</span>
</div>
</div>
<div class="service-card-body" style="display:flex;flex-direction:column;gap:16px;">
<div>
<label><strong>Profiles</strong></label>
<div class="profile-checks">
<label><input type="checkbox" class="profile-check" value="default" checked> Default</label>
<label><input type="checkbox" class="profile-check" value="compress"> Compress</label>
<label><input type="checkbox" class="profile-check" value="hq"> High Quality</label>
</div>
</div>
<div class="rotation-wrap">
<label><input type="checkbox" id="manualRotationToggle"> Manual rotation override</label>
<div id="rotationSelectWrap" style="display:none;margin-top:8px;">
<select id="rotationSelect">
<option value="90">90</option>
<option value="180">180</option>
<option value="270">270</option>
</select>
</div>
</div>
<div class="rotation-wrap">
<label><input type="checkbox" id="mkvAcknowledge"> I understand MKV conversion is not recommended (can increase size and produce poor audio on some sources)</label>
</div>
<div>
<strong>Staged files</strong>
<div id="selected-files" class="selected-box"></div>
</div>
<div class="actions-row">
<button class="portal-btn primary" type="button" onclick="processWorkshop()">Process Checked</button>
<button class="portal-btn" type="button" onclick="selectAllStaged(true)">Check All</button>
<button class="portal-btn" type="button" onclick="selectAllStaged(false)">Uncheck All</button>
<button class="portal-btn" type="button" onclick="removeCheckedStaged()">Remove Checked</button>
<button class="portal-btn" type="button" onclick="loadJobs()">Refresh Jobs</button>
<button class="portal-btn" type="button" onclick="clearWorkshopSelection()">Clear All Staged</button>
</div>
</div>
</div>
<div class="service-card" style="margin-top:18px;">
<div class="service-card-header">
<div>
<h2>Jobs</h2>
<p>Live queue/status feed for this tenant.</p>
</div>
</div>
<div class="service-card-body">
<div id="jobs-panel"></div>
</div>
</div>
</div>
<script>
function fmtBytes(v){
if(v === null || v === undefined || v === "") return "";
const n = Number(v);
if(!Number.isFinite(n)) return String(v);
if(n < 1024) return `${n} B`;
if(n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
if(n < 1024*1024*1024) return `${(n/1024/1024).toFixed(2)} MB`;
return `${(n/1024/1024/1024).toFixed(2)} GB`;
}
function esc(v){
return (v===null||v===undefined) ? "" :
String(v).replaceAll("&","&amp;")
.replaceAll("<","&lt;")
.replaceAll(">","&gt;")
.replaceAll('"',"&quot;");
}
function getSel(){
try{return JSON.parse(localStorage.getItem("videoSelection")||"[]");}
catch{return [];}
}
function setSel(items){
localStorage.setItem("videoSelection", JSON.stringify(items));
}
function renderSel(){
let f=getSel();
let el=document.getElementById("selected-files");
if(!f.length){
el.innerHTML = '<div class="job-empty">No files staged</div>';
return;
}
el.innerHTML = '<div class="staged-list">' + f.map((item, idx) => {
const id = typeof item === "object" ? item.id : item;
const filename = typeof item === "object" ? item.filename : String(item);
const checked = (typeof item === "object" && item.checked === false) ? "" : "checked";
return `
<label class="staged-row">
<input type="checkbox" class="staged-check" data-index="${idx}" ${checked}>
<span class="staged-name">${esc(filename)} [id:${esc(id)}]</span>
</label>
`;
}).join("") + '</div>';
document.querySelectorAll(".staged-check").forEach(cb => {
cb.addEventListener("change", function(){
let items = getSel();
let idx = Number(this.dataset.index);
if(items[idx] && typeof items[idx] === "object"){
items[idx].checked = this.checked;
} else if(items[idx] !== undefined) {
items[idx] = { id: items[idx], filename: String(items[idx]), checked: this.checked };
}
setSel(items);
});
});
}
function clearWorkshopSelection(){
localStorage.removeItem("videoSelection");
renderSel();
}
function selectAllStaged(state){
let items = getSel().map(item => {
if(typeof item === "object"){
item.checked = state;
return item;
}
return { id: item, filename: String(item), checked: state };
});
setSel(items);
renderSel();
}
function removeCheckedStaged(){
let items = getSel().filter(item => {
if(typeof item === "object"){
return item.checked === false;
}
return false;
});
setSel(items);
renderSel();
}
function getCheckedStaged(){
return getSel().filter(item => {
if(typeof item === "object"){
return item.checked !== false;
}
return true;
});
}
function getSelectedProfiles(){
return Array.from(document.querySelectorAll(".profile-check:checked")).map(el => el.value);
}
async function deleteJob(jobId){
if(!confirm("Delete this job from the list?")) return;
const r = await fetch(`/api/video/jobs/${jobId}/delete`, { method: "POST" });
const text = await r.text();
let data = {};
try { data = JSON.parse(text); } catch(e) {}
if(!r.ok){
alert(data.error || text || "Delete failed");
return;
}
loadJobs();
}
async function sendToLTS(jobId){
if(!confirm("Send this output to LTS storage?")) return;
const r = await fetch(`/video-output/${jobId}/send-to-lts`, { method: "POST" });
const text = await r.text();
let data = {};
try { data = JSON.parse(text); } catch(e) {}
if(!r.ok){
alert(data.error || text || "Send to LTS failed");
return;
}
loadJobs();
}
function renderJobs(jobs){
let p=document.getElementById("jobs-panel");
if(!jobs.length){
p.innerHTML='<div class="job-empty">No jobs yet</div>';
return;
}
p.innerHTML='<div class="job-list">'+jobs.map(j=>{
let badge = (j.status||"").toLowerCase();
let deleteBtn = (badge === "failed" || badge === "complete")
? `<button class="portal-btn" type="button" onclick="deleteJob(${Number(j.id)})">Delete</button>`
: "";
let viewBtn = (badge === "complete" && j.output_relative_path)
? `<a class="portal-btn" href="/video-output/${Number(j.id)}/view" target="_blank">View</a>`
: "";
let ltsBtn = (badge === "complete" && j.output_relative_path)
? `<button class="portal-btn" type="button" onclick="sendToLTS(${Number(j.id)})">Send to LTS</button>`
: "";
let downloadBtn = (badge === "complete" && j.output_relative_path)
? `<a class="portal-btn" href="/video-output/${Number(j.id)}/download">Download Output</a>`
: "";
return `
<div class="job-card">
<div class="job-head">
<div class="job-file">${esc(j.filename)}</div>
<div class="job-head-right">
${viewBtn}
${ltsBtn}
${downloadBtn}
${deleteBtn}
<span class="job-badge ${badge}">${esc(j.status).toUpperCase()}</span>
</div>
</div>
<div class="job-grid">
<div class="job-meta"><div class="job-label">Job ID</div><div class="job-value">${esc(j.id)}</div></div>
<div class="job-meta"><div class="job-label">Device</div><div class="job-value">${esc(j.device_id)}</div></div>
<div class="job-meta"><div class="job-label">Profile</div><div class="job-value">${esc(j.profile)}</div></div>
<div class="job-meta"><div class="job-label">Rotation Override</div><div class="job-value">${esc(j.rotation_override || "auto")}</div></div>
<div class="job-meta"><div class="job-label">Processor</div><div class="job-value">${esc(j.assigned_processor||"pending")}</div></div>
<div class="job-meta"><div class="job-label">Progress</div><div class="job-value">${esc(j.progress_percent)}%</div></div>
<div class="job-meta"><div class="job-label">Created</div><div class="job-value">${esc(j.created_at||"")}</div></div>
<div class="job-meta"><div class="job-label">Started</div><div class="job-value">${esc(j.started_at||"")}</div></div>
<div class="job-meta"><div class="job-label">Completed</div><div class="job-value">${esc(j.completed_at||"")}</div></div>
<div class="job-meta"><div class="job-label">Original Size</div><div class="job-value">${esc(fmtBytes(j.original_size)||"")}</div></div>
<div class="job-meta"><div class="job-label">Processed Size</div><div class="job-value">${esc(fmtBytes(j.processed_size)||"")}</div></div>
</div>
${j.output_relative_path ? `<div class="job-path"><div class="job-label">Output</div><div class="job-value">${esc(j.output_relative_path)}</div></div>` : ""}
${j.error_message ? `<div class="job-error"><div class="job-label">Error</div><div class="job-value">${esc(j.error_message)}</div></div>` : ""}
</div>
`;
}).join("")+'</div>';
}
function processWorkshop(){
let files=getCheckedStaged();
if(!files.length){alert("No staged files checked");return;}
const profiles = getSelectedProfiles();
if(!profiles.length){
alert("Select at least one profile");
return;
}
const hasMkv = files.some(item => {
const name = (typeof item === "object" ? item.filename : String(item)) || "";
return name.toLowerCase().endsWith(".mkv");
});
if(hasMkv && !document.getElementById("mkvAcknowledge").checked){
alert("MKV conversion is not recommended. Please acknowledge the MKV warning checkbox if you still want to proceed.");
return;
}
const fileIds = files.map(item => {
if(typeof item === "object") return Number(item.id);
return Number(item);
}).filter(v => Number.isFinite(v));
let rotation_override = null;
if(document.getElementById("manualRotationToggle").checked){
rotation_override = Number(document.getElementById("rotationSelect").value);
}
fetch("/api/video/enqueue",{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
device_id:{{device_id}},
files:fileIds,
profiles:profiles,
rotation_override:rotation_override
})
}).then(()=>loadJobs());
}
function loadJobs(){
fetch("/api/video/jobs")
.then(r=>r.json())
.then(d=>renderJobs(d));
}
document.getElementById("manualRotationToggle").addEventListener("change", function(){
document.getElementById("rotationSelectWrap").style.display = this.checked ? "block" : "none";
});
renderSel();
loadJobs();
setInterval(loadJobs,5000);
</script>
{% endblock %}