Compare commits

...

2 commits

Author SHA1 Message Date
dlawler489
be8776bbce Merge Watched-page refresh, full file-less list, and single collection read
All checks were successful
build-and-push / docker (push) Successful in 30s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:36:41 +10:00
dlawler489
e7c65e28a1 Keep the Watched page current, and reach every model waiting for files
Three things the live run showed:

- The page only polled while a check was already running when it rendered, so
  a page opened between checks showed stale numbers for ever — it missed a
  check started by the schedule or another tab. It now refreshes either way.
- Only the five most recent items were listed, so most models still without
  files had no "Get files" button anywhere. They're now in a collapsible list.
- Adding a watch read the whole collection twice: once to create it, once in
  the first check. Measured on a 535-model collection: the add still takes 52s,
  but the first import now lands 6s later instead of another read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:34:59 +10:00
3 changed files with 30 additions and 4 deletions

View file

@ -45,7 +45,8 @@ async function list() {
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status IN ('imported', 'partial')) AS imported_count, (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status IN ('imported', 'partial')) AS imported_count,
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'partial') AS partial_count, (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'partial') AS partial_count,
(SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'error') AS error_count, (SELECT COUNT(*)::int FROM watched_collection_items i WHERE i.watch_id = w.id AND i.status = 'error') AS error_count,
COALESCE(r.recent, '[]'::json) AS recent COALESCE(r.recent, '[]'::json) AS recent,
COALESCE(q.pending, '[]'::json) AS pending
FROM watched_collections w FROM watched_collections w
LEFT JOIN projects p ON p.id = w.project_id LEFT JOIN projects p ON p.id = w.project_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@ -54,6 +55,13 @@ async function list() {
ORDER BY i.seen_at DESC) AS recent ORDER BY i.seen_at DESC) AS recent
FROM (SELECT * FROM watched_collection_items WHERE watch_id = w.id ORDER BY seen_at DESC LIMIT 5) i FROM (SELECT * FROM watched_collection_items WHERE watch_id = w.id ORDER BY seen_at DESC LIMIT 5) i
) r ON true ) r ON true
-- Every model still waiting for its files, not just the five most recent.
LEFT JOIN LATERAL (
SELECT json_agg(json_build_object('model_id', i.model_id, 'title', i.title) ORDER BY i.seen_at DESC) AS pending
FROM (SELECT * FROM watched_collection_items
WHERE watch_id = w.id AND status = 'partial' AND model_id IS NOT NULL
ORDER BY seen_at DESC LIMIT 200) i
) q ON true
ORDER BY w.id`); ORDER BY w.id`);
return rows; return rows;
} }
@ -88,8 +96,14 @@ async function add({ url, aiTag = false, intervalHours = 6 }) {
`INSERT INTO watched_collections (site, source_id, url, title, project_id, ai_tag, interval_hours, total, listed, last_status) `INSERT INTO watched_collections (site, source_id, url, title, project_id, ai_tag, interval_hours, total, listed, last_status)
VALUES ('makerworld', $1, $2, $3, $4, $5, $6, $7, $8, 'checking') RETURNING id`, VALUES ('makerworld', $1, $2, $3, $4, $5, $6, $7, $8, 'checking') RETURNING id`,
[sourceId, url, collection.title, project.id, Boolean(aiTag), hours, collection.total, collection.designs.length]); [sourceId, url, collection.title, project.id, Boolean(aiTag), hours, collection.total, collection.designs.length]);
checkNow(watch.id).catch((err) => console.warn(`Watch ${watch.id} first check failed:`, err.message)); // The collection was just read, so import straight from it rather than
return get(watch.id); // fetching the whole thing again.
const added = await get(watch.id);
checking.add(watch.id);
importFound(added, collection)
.catch((err) => console.warn(`Watch ${watch.id} first import failed:`, err.message))
.finally(() => checking.delete(watch.id));
return added;
} }
async function update(id, { enabled, aiTag, intervalHours }) { async function update(id, { enabled, aiTag, intervalHours }) {

View file

@ -2204,6 +2204,12 @@ function watchHtml(w) {
? `<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.model_name || i.title || `Model ${i.model_id}`)}</button>` ? `<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.model_name || i.title || `Model ${i.model_id}`)}</button>`
: escapeHtml(i.title || i.design_id)}${i.error ? ` <span class="ai-run-error">${escapeHtml(i.error)}</span>` : ''}${(i.error || i.status === 'partial') && i.model_id : escapeHtml(i.title || i.design_id)}${i.error ? ` <span class="ai-run-error">${escapeHtml(i.error)}</span>` : ''}${(i.error || i.status === 'partial') && i.model_id
? ` <button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button>` : ''}</span>`).join('')}</div>` : ''} ? ` <button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button>` : ''}</span>`).join('')}</div>` : ''}
${(w.pending || []).length ? `<details class="watch-pending">
<summary>${plural(w.partial_count || w.pending.length, 'model')} still without files</summary>
<div class="watch-recent">${w.pending.map((i) => `
<span><button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.title || `Model ${i.model_id}`)}</button>
<button type="button" class="link-btn" data-watch="files" data-model-id="${i.model_id}">Get files</button></span>`).join('')}</div>
</details>` : ''}
</div>`; </div>`;
} }
@ -2230,7 +2236,10 @@ async function refreshWatches() {
list.innerHTML = watches.length list.innerHTML = watches.length
? watches.map(watchHtml).join('') ? watches.map(watchHtml).join('')
: '<p class="form-hint">Nothing watched yet. Paste a MakerWorld collection link above.</p>'; : '<p class="form-hint">Nothing watched yet. Paste a MakerWorld collection link above.</p>';
if (watches.some((w) => w.last_status === 'checking')) watchPoll = setTimeout(refreshWatches, 5000); // Keep the page current even between checks: one may start from the schedule,
// another tab, or another browser. refreshWatches stops itself when you leave.
const checking = watches.some((w) => w.last_status === 'checking');
watchPoll = setTimeout(refreshWatches, checking ? 5000 : 20000);
} }
async function handleAddWatch(e) { async function handleAddWatch(e) {

View file

@ -464,6 +464,9 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.watch-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; } .watch-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; }
.watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; } .watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
.watch-actions .btn-sm { align-self: auto; } .watch-actions .btn-sm { align-self: auto; }
.watch-pending summary { cursor: pointer; font-size: 13px; color: var(--text-muted); }
.watch-pending summary:hover { color: var(--text); }
.watch-pending .watch-recent { margin-top: 6px; }
.watch-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); } .watch-meta { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
.watch-recent { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); max-height: 180px; overflow-y: auto; } .watch-recent { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-muted); max-height: 180px; overflow-y: auto; }
#watchStatus.is-error { color: var(--danger); } #watchStatus.is-error { color: var(--danger); }