fix(photo): preserve photo_jobs history in backup/restore; paginate photo history on worker page
This commit is contained in:
+10
-2
@@ -1,5 +1,6 @@
|
||||
const AUTO_REFRESH_MS = 5000;
|
||||
const RECENT_PAGE_SIZE = 15;
|
||||
const PHOTO_PAGE_SIZE = 15;
|
||||
const STATUS_LABELS = {
|
||||
pending: 'В очереди',
|
||||
processing: 'Проверяется',
|
||||
@@ -23,6 +24,8 @@ let busy = false;
|
||||
let photoBusy = false;
|
||||
let recentRows = [];
|
||||
let recentPage = 1;
|
||||
let photoPage = 1;
|
||||
let photoTotal = 0;
|
||||
|
||||
const PHOTO_ACTION_LABELS = {
|
||||
ai: 'ИИ-обработка',
|
||||
@@ -236,7 +239,10 @@ function renderPhoto() {
|
||||
renderPhotoPending();
|
||||
const tbody = document.querySelector('#photoTable tbody');
|
||||
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';
|
||||
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>';
|
||||
@@ -257,6 +263,7 @@ function renderPhoto() {
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
renderPager(pager, photoPage, maxPage, p => { photoPage = p; loadPhoto(); });
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
@@ -304,9 +311,10 @@ async function loadPhoto() {
|
||||
if (photoBusy) return;
|
||||
photoBusy = true;
|
||||
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;
|
||||
photoState = await res.json();
|
||||
photoTotal = photoState.recent_total || 0;
|
||||
renderPhoto();
|
||||
} catch {
|
||||
} finally {
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
</table>
|
||||
<div class="empty" id="photoEmpty" style="display:none">Обработок фотографий пока нет.</div>
|
||||
</div>
|
||||
<div class="pager" id="photoRecentPager"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card wide" id="errorsCard" style="display:none">
|
||||
|
||||
@@ -1590,13 +1590,29 @@ function normalizeRestoreData(data) {
|
||||
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||
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) => {
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
|
||||
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 students 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 student_photos ORDER BY id'),
|
||||
pool.query('SELECT * FROM share_links ORDER BY id'),
|
||||
pool.query('SELECT * FROM photo_jobs ORDER BY id'),
|
||||
]);
|
||||
const settings = {};
|
||||
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.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
|
||||
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]
|
||||
);
|
||||
}
|
||||
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) {
|
||||
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)',
|
||||
@@ -1803,7 +1828,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
|
||||
[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);
|
||||
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 });
|
||||
});
|
||||
|
||||
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(
|
||||
`SELECT status, count(*)::int AS n FROM photo_jobs GROUP BY status`
|
||||
);
|
||||
const counts = { pending: 0, processing: 0, done: 0, error: 0 };
|
||||
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(
|
||||
`SELECT j.id, j.entry_id, j.action, j.created_at, e.student_name, g.name AS group_name
|
||||
FROM photo_jobs j
|
||||
@@ -4765,6 +4792,12 @@ app.get('/api/photo-jobs/status', requireAdmin, async (_, res) => {
|
||||
WHERE j.status IN ('pending', 'processing')
|
||||
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(
|
||||
`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
|
||||
@@ -4772,7 +4805,8 @@ app.get('/api/photo-jobs/status', requireAdmin, async (_, res) => {
|
||||
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
|
||||
ORDER BY j.finished_at DESC LIMIT 30`
|
||||
ORDER BY j.finished_at DESC LIMIT $1 OFFSET $2`,
|
||||
[recentLimit, recentOffset]
|
||||
),
|
||||
pool.query(
|
||||
`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,
|
||||
pending: pending.rows,
|
||||
recent: recent.rows,
|
||||
recent_total: recentTotal.rows[0].n,
|
||||
errors: errors.rows,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user