From 4371ddb7e7c0808f028f1b8fd8e5034d928c7bfb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 7 Jul 2026 22:20:32 -0400 Subject: [PATCH] feat(translation): live re-translate progress in the Settings card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit translation/status now reports `active` (a translate/retranslate sweep is running, from the TaskRun table) and `last_run` (the most recent finished run's task + status). The Settings card polls live while a sweep runs, showing a spinner + "Translating… N remaining" that ticks down, and flags a last run that ended in error/timeout. No migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/settings.py | 26 +++++++++++++++++ .../components/settings/TranslationCard.vue | 24 ++++++++++++++-- tests/test_api_settings.py | 28 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 6eb7294..53e4a8c 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -16,6 +16,7 @@ from ..models import ( ImportTask, Post, Tag, + TaskRun, ) from ..services import interpreter_client as ic @@ -306,6 +307,10 @@ async def translation_status(): """For the Settings card: is it on, is a URL set, is the service reachable, and how many posts still await translation. Health runs the sync client in a thread so the event loop isn't blocked.""" + translation_tasks = ( + "backend.app.tasks.translation.translate_posts", + "backend.app.tasks.translation.retranslate_posts", + ) async with get_session() as session: cfg = await ImportSettings.load(session) untranslated = (await session.execute( @@ -315,6 +320,21 @@ async def translation_status(): Post.post_title.is_not(None), Post.description.is_not(None), )) )).scalar_one() + # Live progress: is a sweep running now, and what did the last one do? + # (run-until-done re-enqueues itself, so `active` stays true across a + # bulk re-translate; `last_run` surfaces a completed run's outcome.) + active = (await session.execute( + select(func.count(TaskRun.id)) + .where(TaskRun.task_name.in_(translation_tasks)) + .where(TaskRun.status == "running") + )).scalar_one() + last = (await session.execute( + select(TaskRun.task_name, TaskRun.status, TaskRun.finished_at) + .where(TaskRun.task_name.in_(translation_tasks)) + .where(TaskRun.finished_at.is_not(None)) + .order_by(TaskRun.finished_at.desc()) + .limit(1) + )).first() base_url = (cfg.interpreter_base_url or "").strip() healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False return jsonify({ @@ -322,6 +342,12 @@ async def translation_status(): "base_url_set": bool(base_url), "healthy": healthy, "untranslated_count": int(untranslated), + "active": int(active) > 0, + "last_run": { + "task": last[0].rsplit(".", 1)[-1], + "status": last[1], + "finished_at": last[2].isoformat() if last[2] else None, + } if last else None, }) diff --git a/frontend/src/components/settings/TranslationCard.vue b/frontend/src/components/settings/TranslationCard.vue index 754b92e..1e5db35 100644 --- a/frontend/src/components/settings/TranslationCard.vue +++ b/frontend/src/components/settings/TranslationCard.vue @@ -52,11 +52,25 @@ prepend-icon="mdi-refresh" :loading="retranslating" :disabled="!enabled || !baseUrl" @click="confirmAll = true" >Re-translate all - + + + Translating… {{ status.untranslated_count }} remaining + + {{ status.untranslated_count }} post{{ status.untranslated_count === 1 ? '' : 's' }} awaiting translation +

+ Last translation run ended with “{{ status.last_run.status }}”. It resumes on + the next sweep; check the Interpreter connection if it persists. +

Use Re-translate all after switching the Interpreter model — it clears every stored translation and re-runs it through the new model @@ -94,7 +108,7 @@