fix(photo): preserve photo_jobs history in backup/restore; paginate photo history on worker page

This commit is contained in:
dev
2026-09-23 16:37:27 +03:00
parent 30cf04b0cf
commit cb3f010cd9
3 changed files with 53 additions and 9 deletions
+10 -2
View File
@@ -1,5 +1,6 @@
const AUTO_REFRESH_MS = 5000; const AUTO_REFRESH_MS = 5000;
const RECENT_PAGE_SIZE = 15; const RECENT_PAGE_SIZE = 15;
const PHOTO_PAGE_SIZE = 15;
const STATUS_LABELS = { const STATUS_LABELS = {
pending: 'В очереди', pending: 'В очереди',
processing: 'Проверяется', processing: 'Проверяется',
@@ -23,6 +24,8 @@ let busy = false;
let photoBusy = false; let photoBusy = false;
let recentRows = []; let recentRows = [];
let recentPage = 1; let recentPage = 1;
let photoPage = 1;
let photoTotal = 0;
const PHOTO_ACTION_LABELS = { const PHOTO_ACTION_LABELS = {
ai: 'ИИ-обработка', ai: 'ИИ-обработка',
@@ -236,7 +239,10 @@ function renderPhoto() {
renderPhotoPending(); renderPhotoPending();
const tbody = document.querySelector('#photoTable tbody'); const tbody = document.querySelector('#photoTable tbody');
const empty = document.getElementById('photoEmpty'); const empty = document.getElementById('photoEmpty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; } const pager = document.getElementById('photoRecentPager');
const maxPage = Math.max(1, Math.ceil(photoTotal / PHOTO_PAGE_SIZE));
if (photoPage > maxPage) photoPage = maxPage;
if (!photoTotal) { tbody.innerHTML = ''; empty.style.display = 'block'; renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); }); return; }
empty.style.display = 'none'; empty.style.display = 'none';
tbody.innerHTML = rows.map(r => { tbody.innerHTML = rows.map(r => {
const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>'; const before = r.before_path ? `<img class="photo-thumb" loading="lazy" src="${esc(thumbUrl(r.before_path))}" alt="было">` : '<span style="color:var(--muted)">—</span>';
@@ -257,6 +263,7 @@ function renderPhoto() {
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); });
renderIcons(); renderIcons();
} }
@@ -304,9 +311,10 @@ async function loadPhoto() {
if (photoBusy) return; if (photoBusy) return;
photoBusy = true; photoBusy = true;
try { try {
const res = await fetch(`${API}/api/photo-jobs/status`, { headers: hdr() }); const res = await fetch(`${API}/api/photo-jobs/status?recent_limit=${PHOTO_PAGE_SIZE}&recent_offset=${(photoPage - 1) * PHOTO_PAGE_SIZE}`, { headers: hdr() });
if (!res.ok) return; if (!res.ok) return;
photoState = await res.json(); photoState = await res.json();
photoTotal = photoState.recent_total || 0;
renderPhoto(); renderPhoto();
} catch { } catch {
} finally { } finally {
+1
View File
@@ -125,6 +125,7 @@
</table> </table>
<div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div> <div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div>
</div> </div>
<div class="pager" id="photoRecentPager"></div>
</div> </div>
<div class="settings-card wide" id="errorsCard" style="display:none"> <div class="settings-card wide" id="errorsCard" style="display:none">
+42 -7
View File
@@ -1590,13 +1590,29 @@ function normalizeRestoreData(data) {
for (const [k, v] of Object.entries(data.settings || {})) { for (const [k, v] of Object.entries(data.settings || {})) {
settings[reqStr(k, 100)] = reqStr(String(v), 10000); settings[reqStr(k, 100)] = reqStr(String(v), 10000);
} }
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules }; const PHOTO_JOB_ACTIONS = new Set(['ai', 'enhance', 'restore', 'rollback']);
const PHOTO_JOB_STATUSES = new Set(['pending', 'processing', 'done', 'error', 'rejected']);
const photo_jobs = (data.photo_jobs || []).map(x => ({
id: reqInt(x.id),
entry_id: reqInt(x.entry_id),
action: (x.action && PHOTO_JOB_ACTIONS.has(x.action)) ? x.action : 'ai',
params: (x.params === null || x.params === undefined) ? null : (typeof x.params === 'object' ? JSON.stringify(x.params) : String(x.params)),
before_path: optUploadPath(x.before_path, 255),
after_path: optUploadPath(x.after_path, 255),
status: (x.status && PHOTO_JOB_STATUSES.has(x.status)) ? x.status : 'pending',
applied: !!x.applied,
attempts: optInt(x.attempts, 0, 2147483647) ?? 0,
error: optStr(x.error, 4000),
created_at: optTs(x.created_at),
finished_at: optTs(x.finished_at),
}));
return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, student_photos, group_photos, share_links, modules, photo_jobs };
} }
app.get('/api/backup', requireAdmin, async (req, res) => { app.get('/api/backup', requireAdmin, async (req, res) => {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-')); const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try { try {
const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl] = await Promise.all([ const [g, s, e, st, pf, br, us, ub, gp, ep, md, sp, sl, pj] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'), pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'), pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'), pool.query('SELECT * FROM entries ORDER BY id'),
@@ -1610,10 +1626,11 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
pool.query('SELECT * FROM modules ORDER BY id'), pool.query('SELECT * FROM modules ORDER BY id'),
pool.query('SELECT * FROM student_photos ORDER BY id'), pool.query('SELECT * FROM student_photos ORDER BY id'),
pool.query('SELECT * FROM share_links ORDER BY id'), pool.query('SELECT * FROM share_links ORDER BY id'),
pool.query('SELECT * FROM photo_jobs ORDER BY id'),
]); ]);
const settings = {}; const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; }); st.rows.forEach(r => { settings[r.key] = r.value; });
const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows }; const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows, student_photos: sp.rows, share_links: sl.rows, photo_jobs: pj.rows };
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload)); fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true }); fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
const dir = path.join(__dirname, 'uploads'); const dir = path.join(__dirname, 'uploads');
@@ -1771,6 +1788,14 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.photo_original_path, x.deleted_at, x.created_at] [x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.photo_original_path, x.deleted_at, x.created_at]
); );
} }
for (const x of ndata.photo_jobs) {
const ex = await client.query('SELECT 1 FROM entries WHERE id = $1', [x.entry_id]);
if (!ex.rowCount) continue;
await client.query(
'INSERT INTO photo_jobs (id, entry_id, action, params, before_path, after_path, status, applied, attempts, error, created_at, finished_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
[x.id, x.entry_id, x.action, x.params, x.before_path, x.after_path, x.status, x.applied, x.attempts, x.error, x.created_at, x.finished_at]
);
}
for (const x of ndata.project_files) { for (const x of ndata.project_files) {
await client.query( await client.query(
'INSERT INTO project_files (id, entry_id, token, path, name, detached_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)', 'INSERT INTO project_files (id, entry_id, token, path, name, detached_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
@@ -1803,7 +1828,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[k, String(v ?? '')] [k, String(v ?? '')]
); );
} }
for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links']) { for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules', 'student_photos', 'share_links', 'photo_jobs']) {
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl); const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]); await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
} }
@@ -4750,13 +4775,15 @@ app.post('/api/entries/:id/ai/revert', requireAuth, async (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get('/api/photo-jobs/status', requireAdmin, async (_, res) => { app.get('/api/photo-jobs/status', requireAdmin, async (req, res) => {
const recentLimit = Math.min(Math.max(optInt(req.query.recent_limit, 1, 200) ?? 15, 1), 200);
const recentOffset = Math.max(optInt(req.query.recent_offset, 0, 2147483647) ?? 0, 0);
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT status, count(*)::int AS n FROM photo_jobs GROUP BY status` `SELECT status, count(*)::int AS n FROM photo_jobs GROUP BY status`
); );
const counts = { pending: 0, processing: 0, done: 0, error: 0 }; const counts = { pending: 0, processing: 0, done: 0, error: 0 };
rows.forEach(r => { counts[r.status] = r.n; }); rows.forEach(r => { counts[r.status] = r.n; });
const [pending, recent, errors] = await Promise.all([ const [pending, recentTotal, recent, errors] = await Promise.all([
pool.query( pool.query(
`SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name `SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name
FROM photo_jobs j FROM photo_jobs j
@@ -4765,6 +4792,12 @@ app.get('/api/photo-jobs/status', requireAdmin, async (_, res) => {
WHERE j.status IN ('pending', 'processing') WHERE j.status IN ('pending', 'processing')
ORDER BY j.id ASC LIMIT 20` ORDER BY j.id ASC LIMIT 20`
), ),
pool.query(
`SELECT count(*)::int AS n FROM photo_jobs j
JOIN entries e ON e.id = j.entry_id
JOIN groups g ON g.id = e.group_id
WHERE j.finished_at IS NOT NULL`
),
pool.query( pool.query(
`SELECT j.id, j.entry_id, j.action, j.status, j.params, j.before_path, j.after_path, j.error, j.created_at, j.finished_at, `SELECT j.id, j.entry_id, j.action, j.status, j.params, j.before_path, j.after_path, j.error, j.created_at, j.finished_at,
e.student_name, g.name AS group_name e.student_name, g.name AS group_name
@@ -4772,7 +4805,8 @@ app.get('/api/photo-jobs/status', requireAdmin, async (_, res) => {
JOIN entries e ON e.id = j.entry_id JOIN entries e ON e.id = j.entry_id
JOIN groups g ON g.id = e.group_id JOIN groups g ON g.id = e.group_id
WHERE j.finished_at IS NOT NULL WHERE j.finished_at IS NOT NULL
ORDER BY j.finished_at DESC LIMIT 30` ORDER BY j.finished_at DESC LIMIT $1 OFFSET $2`,
[recentLimit, recentOffset]
), ),
pool.query( pool.query(
`SELECT j.id, j.entry_id, j.action, j.error, j.finished_at, e.student_name, g.name AS group_name `SELECT j.id, j.entry_id, j.action, j.error, j.finished_at, e.student_name, g.name AS group_name
@@ -4793,6 +4827,7 @@ app.get('/api/photo-jobs/status', requireAdmin, async (_, res) => {
counts, counts,
pending: pending.rows, pending: pending.rows,
recent: recent.rows, recent: recent.rows,
recent_total: recentTotal.rows[0].n,
errors: errors.rows, errors: errors.rows,
}); });
}); });