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,
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user