feat(translation): live re-translate progress in the Settings card
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:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -52,11 +52,25 @@
|
||||
prepend-icon="mdi-refresh" :loading="retranslating"
|
||||
:disabled="!enabled || !baseUrl" @click="confirmAll = true"
|
||||
>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 }}
|
||||
post{{ status.untranslated_count === 1 ? '' : 's' }} awaiting translation
|
||||
</span>
|
||||
</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">
|
||||
Use <strong>Re-translate all</strong> after switching the Interpreter model —
|
||||
it clears every stored translation and re-runs it through the new model
|
||||
@@ -94,7 +108,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
@@ -126,10 +140,16 @@ const statusText = computed(() => {
|
||||
return status.value.healthy ? 'Interpreter reachable' : 'Interpreter unreachable'
|
||||
})
|
||||
|
||||
let pollTimer = null
|
||||
async function loadStatus() {
|
||||
try { status.value = await api.get('/api/settings/translation/status') }
|
||||
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() {
|
||||
// Ping /v1/health for the CURRENTLY-typed URL (not the saved one), so a new
|
||||
|
||||
@@ -61,6 +61,34 @@ async def test_translation_status_defaults(client):
|
||||
assert body["base_url_set"] is False
|
||||
assert body["healthy"] is False
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user