feat(translation): live re-translate progress in the Settings card
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m45s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:20:32 -04:00
parent 40cc11be5b
commit 4371ddb7e7
3 changed files with 76 additions and 2 deletions
+26
View File
@@ -16,6 +16,7 @@ from ..models import (
ImportTask, ImportTask,
Post, Post,
Tag, Tag,
TaskRun,
) )
from ..services import interpreter_client as ic 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, """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 and how many posts still await translation. Health runs the sync client in a
thread so the event loop isn't blocked.""" 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: async with get_session() as session:
cfg = await ImportSettings.load(session) cfg = await ImportSettings.load(session)
untranslated = (await session.execute( untranslated = (await session.execute(
@@ -315,6 +320,21 @@ async def translation_status():
Post.post_title.is_not(None), Post.description.is_not(None), Post.post_title.is_not(None), Post.description.is_not(None),
)) ))
)).scalar_one() )).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() base_url = (cfg.interpreter_base_url or "").strip()
healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
return jsonify({ return jsonify({
@@ -322,6 +342,12 @@ async def translation_status():
"base_url_set": bool(base_url), "base_url_set": bool(base_url),
"healthy": healthy, "healthy": healthy,
"untranslated_count": int(untranslated), "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,
}) })
@@ -52,11 +52,25 @@
prepend-icon="mdi-refresh" :loading="retranslating" prepend-icon="mdi-refresh" :loading="retranslating"
:disabled="!enabled || !baseUrl" @click="confirmAll = true" :disabled="!enabled || !baseUrl" @click="confirmAll = true"
>Re-translate all</v-btn> >Re-translate all</v-btn>
<span v-if="status" class="fc-muted text-caption"> <span
v-if="status && status.active"
class="fc-muted text-caption d-inline-flex align-center" style="gap: 6px;"
>
<v-progress-circular indeterminate size="12" width="2" color="accent" />
Translating… {{ status.untranslated_count }} remaining
</span>
<span v-else-if="status" class="fc-muted text-caption">
{{ status.untranslated_count }} {{ status.untranslated_count }}
post{{ status.untranslated_count === 1 ? '' : 's' }} awaiting translation post{{ status.untranslated_count === 1 ? '' : 's' }} awaiting translation
</span> </span>
</div> </div>
<p
v-if="status && status.last_run && ['error', 'timeout'].includes(status.last_run.status)"
class="text-caption text-error mt-1 mb-0"
>
Last translation run ended with “{{ status.last_run.status }}”. It resumes on
the next sweep; check the Interpreter connection if it persists.
</p>
<p class="fc-muted text-caption mt-2 mb-0"> <p class="fc-muted text-caption mt-2 mb-0">
Use <strong>Re-translate all</strong> after switching the Interpreter model — Use <strong>Re-translate all</strong> after switching the Interpreter model —
it clears every stored translation and re-runs it through the new model it clears every stored translation and re-runs it through the new model
@@ -94,7 +108,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
@@ -126,10 +140,16 @@ const statusText = computed(() => {
return status.value.healthy ? 'Interpreter reachable' : 'Interpreter unreachable' return status.value.healthy ? 'Interpreter reachable' : 'Interpreter unreachable'
}) })
let pollTimer = null
async function loadStatus() { async function loadStatus() {
try { status.value = await api.get('/api/settings/translation/status') } try { status.value = await api.get('/api/settings/translation/status') }
catch { status.value = null } catch { status.value = null }
// Poll live while a sweep is running so the remaining count ticks down; stop
// when idle (a fresh trigger restarts it via its own setTimeout(loadStatus)).
clearTimeout(pollTimer)
if (status.value?.active) pollTimer = setTimeout(loadStatus, 3000)
} }
onUnmounted(() => clearTimeout(pollTimer))
async function onTest() { async function onTest() {
// Ping /v1/health for the CURRENTLY-typed URL (not the saved one), so a new // Ping /v1/health for the CURRENTLY-typed URL (not the saved one), so a new
+28
View File
@@ -61,6 +61,34 @@ async def test_translation_status_defaults(client):
assert body["base_url_set"] is False assert body["base_url_set"] is False
assert body["healthy"] is False assert body["healthy"] is False
assert "untranslated_count" in body assert "untranslated_count" in body
assert body["active"] is False # no sweep running
assert body["last_run"] is None
@pytest.mark.asyncio
async def test_translation_status_reports_active_and_last_run(client, db):
# A running translate/retranslate TaskRun → active True; the most recent
# finished one → last_run (task basename + status).
from datetime import UTC, datetime
from backend.app.models import TaskRun
db.add(TaskRun(
celery_task_id="r-run", queue="maintenance_long",
task_name="backend.app.tasks.translation.retranslate_posts",
started_at=datetime.now(UTC), status="running",
))
db.add(TaskRun(
celery_task_id="r-done", queue="maintenance_long",
task_name="backend.app.tasks.translation.translate_posts",
started_at=datetime.now(UTC), finished_at=datetime.now(UTC),
status="success",
))
await db.commit()
body = await (await client.get("/api/settings/translation/status")).get_json()
assert body["active"] is True
assert body["last_run"]["task"] == "translate_posts"
assert body["last_run"]["status"] == "success"
@pytest.mark.asyncio @pytest.mark.asyncio