feat: paginate filter and delete operation history
This commit is contained in:
@@ -14,6 +14,7 @@ from fastapi.openapi.docs import get_swagger_ui_html
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from pigway_frame_flow.api.history import install_history
|
||||
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
|
||||
from pigway_frame_flow.core.auth import require, seed_client
|
||||
from pigway_frame_flow.core.registry import DeviceRegistry
|
||||
@@ -132,6 +133,7 @@ def create_app(settings=None, start_worker=True):
|
||||
)
|
||||
install_extensions(app, service)
|
||||
install_accounts(app, db)
|
||||
install_history(app, service)
|
||||
app.state.service = service
|
||||
app.state.worker_enabled = start_worker
|
||||
app.state.scheduler = scheduler
|
||||
@@ -842,7 +844,7 @@ def create_app(settings=None, start_worker=True):
|
||||
return [
|
||||
service.operation(x["id"])
|
||||
for x in db.all(
|
||||
"SELECT id FROM operations WHERE (? OR client_id=?) ORDER BY created_at DESC LIMIT ?",
|
||||
"SELECT id FROM operations WHERE (? OR client_id=?) AND NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=operations.id) ORDER BY created_at DESC,rowid DESC LIMIT ?",
|
||||
("admin" in request.state.permissions, request.state.client_id, limit),
|
||||
)
|
||||
]
|
||||
@@ -1001,7 +1003,10 @@ def create_app(settings=None, start_worker=True):
|
||||
dependencies=[Depends(require("history:read"))],
|
||||
)
|
||||
def history(limit: int = Query(default=100, ge=1, le=200)):
|
||||
return db.all("SELECT * FROM history ORDER BY id DESC LIMIT ?", (limit,))
|
||||
return db.all(
|
||||
"SELECT * FROM history WHERE NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=history.operation_id) ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/api/v1/settings",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Paginated history without removing operation receipts used by other services."""
|
||||
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import Depends, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from pigway_frame_flow.core.auth import require
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.models import Operation, OperationStatus
|
||||
|
||||
|
||||
class HistoryPage(BaseModel):
|
||||
items: list[Operation]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class HistoryDelete(BaseModel):
|
||||
operation_ids: list[str] = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
def install_history(app, service):
|
||||
db = service.db
|
||||
|
||||
@app.get(
|
||||
"/api/v1/operation-history",
|
||||
response_model=HistoryPage,
|
||||
tags=["Operations"],
|
||||
dependencies=[Depends(require("history:read"))],
|
||||
)
|
||||
def list_history(
|
||||
request: Request,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: OperationStatus | None = None,
|
||||
action: str = Query("", max_length=80),
|
||||
device_id: str = Query("", max_length=120),
|
||||
q: str = Query("", max_length=200),
|
||||
start: date | None = None,
|
||||
end: date | None = None,
|
||||
):
|
||||
if start and end and start > end:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Start date must not follow end date", 422
|
||||
)
|
||||
clauses = [
|
||||
"NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=o.id)"
|
||||
]
|
||||
args: list = []
|
||||
if "admin" not in request.state.permissions:
|
||||
clauses.append("o.client_id=?")
|
||||
args.append(request.state.client_id)
|
||||
for column, value in [
|
||||
("status", status),
|
||||
("action", action),
|
||||
("device_id", device_id),
|
||||
]:
|
||||
if value:
|
||||
clauses.append(f"o.{column}=?")
|
||||
args.append(value)
|
||||
if q.strip():
|
||||
clauses.append(
|
||||
"instr(lower(o.id||' '||o.request_id||' '||o.client_id||' '||coalesce(o.result,'')),lower(?))>0"
|
||||
)
|
||||
args.append(q.strip())
|
||||
for day, operator, offset in [(start, ">=", 0), (end, "<", 1)]:
|
||||
if day:
|
||||
boundary = datetime.combine(
|
||||
day, time(), ZoneInfo("Asia/Shanghai")
|
||||
) + timedelta(days=offset)
|
||||
clauses.append(f"o.created_at{operator}?")
|
||||
args.append(boundary.astimezone(UTC).isoformat())
|
||||
where = " AND ".join(clauses)
|
||||
# Keep count and rows consistent if a worker finishes or a deletion occurs.
|
||||
with db.lock:
|
||||
total = db.one(
|
||||
"SELECT count(*) AS total FROM operations o WHERE " + where, args
|
||||
)["total"]
|
||||
page = min(page, max(1, (total + page_size - 1) // page_size))
|
||||
rows = db.all(
|
||||
"SELECT o.id FROM operations o WHERE "
|
||||
+ where
|
||||
+ " ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?",
|
||||
[*args, page_size, (page - 1) * page_size],
|
||||
)
|
||||
return HistoryPage(
|
||||
items=[service.operation(row["id"]) for row in rows],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
@app.delete(
|
||||
"/api/v1/operation-history",
|
||||
tags=["Operations"],
|
||||
dependencies=[Depends(require("admin"))],
|
||||
)
|
||||
def delete_history(payload: HistoryDelete):
|
||||
ids = list(dict.fromkeys(payload.operation_ids))
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with db.transaction() as connection:
|
||||
rows = connection.execute(
|
||||
f"SELECT id,status FROM operations WHERE id IN ({placeholders})", ids
|
||||
).fetchall()
|
||||
if len(rows) != len(ids):
|
||||
raise FlowError(E.NOT_FOUND, "History record not found", 404)
|
||||
if any(
|
||||
row["status"] not in {"COMPLETED", "FAILED", "UNKNOWN"} for row in rows
|
||||
):
|
||||
raise FlowError(E.CONFLICT, "Running operations cannot be deleted", 409)
|
||||
before = connection.total_changes
|
||||
connection.executemany(
|
||||
"INSERT OR IGNORE INTO hidden_history(operation_id) VALUES(?)",
|
||||
[(key,) for key in ids],
|
||||
)
|
||||
deleted = connection.total_changes - before
|
||||
return {"deleted": deleted}
|
||||
@@ -45,6 +45,7 @@ class Database:
|
||||
last_run TEXT, last_result TEXT);
|
||||
CREATE TABLE IF NOT EXISTS job_runs (job_id TEXT, scheduled_at TEXT,
|
||||
operation_id TEXT, PRIMARY KEY(job_id,scheduled_at));
|
||||
CREATE TABLE IF NOT EXISTS hidden_history (operation_id TEXT PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS history (id INTEGER PRIMARY KEY, client_id TEXT,
|
||||
request_id TEXT, operation_id TEXT, device_id TEXT, action TEXT,
|
||||
result TEXT, timestamp TEXT);
|
||||
|
||||
@@ -151,7 +151,7 @@ const views={
|
||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async history(epoch){const data=await api('/api/v1/operation-history?'+historyQuery());if(epoch!==state.epoch)return;state.historyPageData=data;state.historyPage=data.page;const ops=data.items;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||
};
|
||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
|
||||
|
||||
@@ -45,7 +45,8 @@ async function weatherSettings(){
|
||||
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
|
||||
}
|
||||
async function enhanceHistory(){
|
||||
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${(current.result?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
|
||||
historyControls();
|
||||
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);const op=state.historyPageData?.items.find(x=>x.operation_id===operationId);if(op&&['COMPLETED','FAILED','UNKNOWN'].includes(op.status)){const remove=document.createElement('button');remove.textContent=t('delete');remove.className='danger';remove.onclick=()=>deleteHistory([operationId]);actions.append(remove);}button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${(current.result?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
|
||||
async function enhanceAutomation(){
|
||||
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
||||
}
|
||||
@@ -166,3 +167,25 @@ async function previewSource(id,item_id=null){
|
||||
async function sourceJobForm(id){
|
||||
await jobEditor({name:t('randomPhoto'),device_id:state.device,enabled:false,schedule:{type:'interval',interval_minutes:120},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'},display_duration:0,restore_previous:false,priority:50});
|
||||
}
|
||||
|
||||
function historyQuery(){return new URLSearchParams({...state.historyFilters,page:state.historyPage||1,page_size:state.historyPageSize||20});}
|
||||
function historyControls(){
|
||||
const data=state.historyPageData;if(!data)return;
|
||||
const filters=state.historyFilters||{};
|
||||
const form=document.createElement('form');form.id='history-filters';form.className='card history-filters';
|
||||
const select=(id,label,options)=>`<div class="field"><label for="${id}">${t(label)}</label><select id="${id}">${options}</select></div>`;
|
||||
form.innerHTML=`<div class="field"><label for="history-search">${t('searchHistory')}</label><input id="history-search" type="search" maxlength="200" value="${esc(filters.q||'')}"></div>`+select('history-status','status',`<option value="">${t('allStatuses')}</option>`+['QUEUED','RENDERING','UPLOADING','SYNCING','COMPLETED','FAILED','UNKNOWN'].map(x=>`<option value="${x}">${t(x)}</option>`).join(''))+select('history-action','action',`<option value="">${t('allActions')}</option>`+['display','render','render-ai','local-upload','delete','restore','official-widget','reconcile'].map(x=>`<option value="${x}">${esc(operationActionLabel(x))}</option>`).join(''))+select('history-device','device',`<option value="">${t('allDevices')}</option>`+state.devices.map(d=>`<option value="${esc(d.id)}">${esc(deviceNameForId(d.id))}</option>`).join(''))+`<div class="field"><label for="history-start">${t('startDate')}</label><input id="history-start" type="date" value="${esc(filters.start||'')}"></div><div class="field"><label for="history-end">${t('endDate')}</label><input id="history-end" type="date" value="${esc(filters.end||'')}"></div><div class="actions"><button type="submit" class="primary">${t('filter')}</button><button id="history-reset" type="button">${t('resetFilters')}</button></div>`;
|
||||
const wrap=$('#main .table-wrap');wrap.before(form);
|
||||
for(const key of ['status','action','device'])$('#history-'+key).value=filters[key==='device'?'device_id':key]||'';
|
||||
form.onsubmit=busy(async e=>{e.preventDefault();const start=$('#history-start'),end=$('#history-end');end.setCustomValidity(start.value&&end.value&&start.value>end.value?t('dateRangeInvalid'):'');if(!form.reportValidity())return;state.historyFilters={};for(const [id,key] of [['search','q'],['status','status'],['action','action'],['device','device_id'],['start','start'],['end','end']]){const value=$('#history-'+id).value;if(value)state.historyFilters[key]=value;}state.historyPage=1;await navigate();});
|
||||
$('#history-end').oninput=()=>$('#history-end').setCustomValidity('');$('#history-start').oninput=()=>$('#history-end').setCustomValidity('');
|
||||
bind('history-reset',async()=>{state.historyFilters={};state.historyPage=1;await navigate();});
|
||||
const bar=document.createElement('div');bar.className='actions history-toolbar';bar.innerHTML=`<button id="history-all">${t('selectAll')}</button><button id="history-invert">${t('invertSelection')}</button><button id="history-delete" class="danger" disabled>${t('deleteSelected')}</button><span id="history-selected" class="hint" role="status"></span>`;wrap.before(bar);
|
||||
const pager=document.createElement('div');pager.className='actions history-pagination';const pages=Math.max(1,Math.ceil(data.total/data.page_size));pager.innerHTML=`<span>${t('historyTotal').replace('{count}',data.total)} · ${data.page} / ${pages}</span><button id="history-prev" ${data.page<=1?'disabled':''}>${t('previousPage')}</button><button id="history-next" ${data.page>=pages?'disabled':''}>${t('nextPage')}</button><label for="history-page-size">${t('pageSize')}</label><select id="history-page-size">${[20,50,100].map(n=>`<option value="${n}" ${n===data.page_size?'selected':''}>${n}</option>`).join('')}</select>`;wrap.after(pager);
|
||||
bind('history-prev',async()=>{state.historyPage=data.page-1;await navigate();});bind('history-next',async()=>{state.historyPage=data.page+1;await navigate();});$('#history-page-size').onchange=busy(async()=>{state.historyPageSize=Number($('#history-page-size').value);state.historyPage=1;await navigate();});
|
||||
const checkboxes=()=>[...document.querySelectorAll('.history-select:not(:disabled)')];
|
||||
const update=()=>{const selected=checkboxes().filter(x=>x.checked);$('#history-delete').disabled=!selected.length;$('#history-selected').textContent=t('historySelected').replace('{count}',selected.length);};
|
||||
for(const op of data.items){const row=[...wrap.querySelectorAll('tbody tr')].find(r=>r.dataset.operationId===op.operation_id);if(!row)continue;const box=document.createElement('input');box.type='checkbox';box.className='history-select';box.value=op.operation_id;box.setAttribute('aria-label',t('selectHistory')+' '+op.operation_id);box.disabled=!['COMPLETED','FAILED','UNKNOWN'].includes(op.status);box.onchange=update;row.cells[0].prepend(box);}
|
||||
bind('history-all',()=>{const boxes=checkboxes(),checked=boxes.length>0&&boxes.every(x=>x.checked);boxes.forEach(x=>x.checked=!checked);update();});bind('history-invert',()=>{checkboxes().forEach(x=>x.checked=!x.checked);update();});bind('history-delete',()=>deleteHistory(checkboxes().filter(x=>x.checked).map(x=>x.value)));update();
|
||||
}
|
||||
async function deleteHistory(ids){await confirmDialog(t('deleteHistoryConfirm').replace('{count}',ids.length),async()=>{await api('/api/v1/operation-history',{method:'DELETE',body:{operation_ids:ids}});await navigate();});}
|
||||
|
||||
@@ -149,3 +149,6 @@ Object.assign(window.MESSAGES.zh,{signIn:'登录',livingRoom:'客厅'});Object.a
|
||||
|
||||
Object.assign(MESSAGES.zh,{RESTORE_IMAGE_PROTECTED:'这张图片仍用于恢复显示,暂时不能删除。请先结束临时展示,或显示另一张图片以更新恢复目标。'});
|
||||
Object.assign(MESSAGES.en,{RESTORE_IMAGE_PROTECTED:'This image is still needed to restore the display. End the temporary display or show another image to update the restore target before deleting it.'});
|
||||
|
||||
Object.assign(MESSAGES.zh,{searchHistory:'搜索图片名称、请求 ID、任务 ID 或来源',allStatuses:'全部状态',allActions:'全部操作',allDevices:'全部设备',startDate:'开始日期',endDate:'结束日期',filter:'筛选',resetFilters:'重置筛选',dateRangeInvalid:'结束日期不能早于开始日期',historyTotal:'共 {count} 条',pageSize:'每页条数',historySelected:'已选 {count} 条(仅当前页)',selectHistory:'选择记录',deleteHistoryConfirm:'确认删除这 {count} 条历史记录?记录将从历史列表移除,不会删除图片或取消任务。此操作无法撤销。'});
|
||||
Object.assign(MESSAGES.en,{searchHistory:'Search image name, request ID, operation ID or client',allStatuses:'All statuses',allActions:'All actions',allDevices:'All devices',startDate:'Start date',endDate:'End date',filter:'Filter',resetFilters:'Reset filters',dateRangeInvalid:'End date must not precede start date',historyTotal:'{count} records',pageSize:'Rows per page',historySelected:'{count} selected (current page only)',selectHistory:'Select record',deleteHistoryConfirm:'Delete these {count} history records? This removes them from history without deleting images or cancelling tasks. This cannot be undone.'});
|
||||
|
||||
@@ -49,7 +49,7 @@ input[type="file"]::file-selector-button{margin-inline-end:12px}
|
||||
|
||||
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
|
||||
|
||||
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
|
||||
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:180px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:190px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
|
||||
|
||||
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}
|
||||
|
||||
@@ -238,3 +238,12 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f
|
||||
/* Stacked wordmark keeps the product name readable within the sidebar. */
|
||||
.sidebar .brand{font-size:26px;font-weight:800;line-height:1.15;letter-spacing:1px}
|
||||
.sidebar .brand small{margin-top:6px;font-size:12px;font-weight:700;letter-spacing:4px;line-height:1.4;white-space:nowrap}
|
||||
|
||||
form#history-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}
|
||||
#history-filters .field{min-width:0;margin:0}
|
||||
#history-filters>.actions{grid-column:1/-1}
|
||||
.history-select{margin:0 10px 0 0;vertical-align:middle}
|
||||
.history-actions button+button{margin-left:10px}
|
||||
.history-pagination select{width:auto}
|
||||
@media(max-width:1050px){form#history-filters{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:720px){form#history-filters{grid-template-columns:minmax(0,1fr)}}
|
||||
|
||||
Reference in New Issue
Block a user