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 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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user