Skip to content

Commit 5f320e1

Browse files
eprifticlaude
andcommitted
Add job progress ETA, circular progress indicator, toast notifications, and fix SQLite locking
- ConsolePanel: time estimation (ETA) based on elapsed time and generation progress - ProgressCircle: SVG circular progress component shown in minimized console bar - ProjectDashboard: progress info + ETA in minimized bar, in-app toast on job complete/fail - ResultsTab: auto-refresh job list and results when job status changes - database.py: SQLite WAL mode + busy timeout for concurrent async/sync access - analysis.py: commit audit entry before dispatching background task - test_api.py: fix DB fixture to match real get_db commit behavior, fix 2 engine-dependent tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 317d1b2 commit 5f320e1

7 files changed

Lines changed: 220 additions & 15 deletions

File tree

‎backend/app/core/database.py‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Async SQLAlchemy database setup."""
22

3-
from sqlalchemy import create_engine as create_sync_engine
3+
from sqlalchemy import create_engine as create_sync_engine, event
44
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
55
from sqlalchemy.orm import DeclarativeBase, sessionmaker
66

@@ -11,15 +11,39 @@ class Base(DeclarativeBase):
1111
pass
1212

1313

14+
_is_sqlite = "sqlite" in settings.database_url
15+
_sqlite_connect_args = {"timeout": 30, "check_same_thread": False} if _is_sqlite else {}
16+
17+
18+
def _sqlite_wal_pragma(dbapi_conn, connection_record):
19+
"""Enable WAL mode for SQLite concurrent access."""
20+
cursor = dbapi_conn.cursor()
21+
cursor.execute("PRAGMA journal_mode=WAL")
22+
cursor.close()
23+
24+
1425
# Async engine — used by FastAPI request handlers
15-
engine = create_async_engine(settings.database_url, echo=settings.debug)
26+
engine = create_async_engine(
27+
settings.database_url,
28+
echo=settings.debug,
29+
connect_args=_sqlite_connect_args if _is_sqlite else {},
30+
)
1631
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
1732

1833
# Sync engine — used by background tasks (which run in thread pools)
1934
_sync_url = settings.database_url.replace("+asyncpg", "").replace("+aiosqlite", "")
20-
sync_engine = create_sync_engine(_sync_url, echo=settings.debug)
35+
sync_engine = create_sync_engine(
36+
_sync_url,
37+
echo=settings.debug,
38+
connect_args=_sqlite_connect_args if _is_sqlite else {},
39+
)
2140
sync_session_factory = sessionmaker(sync_engine, expire_on_commit=False)
2241

42+
# Enable WAL mode for SQLite to allow concurrent reads/writes
43+
if _is_sqlite:
44+
event.listen(sync_engine, "connect", _sqlite_wal_pragma)
45+
event.listen(engine.sync_engine, "connect", _sqlite_wal_pragma)
46+
2347

2448
async def get_db():
2549
"""FastAPI dependency that yields a database session."""

‎backend/app/routers/analysis.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ async def _resolve_file(file_id: str) -> Optional[str]:
111111

112112
await audit.log_action(db, user, audit.ACTION_JOB_LAUNCH, "job", job.id,
113113
details={"project_id": project_id})
114+
await db.commit() # Ensure all writes are flushed before background task starts
114115

115116
# Run in background thread
116117
background_tasks.add_task(_run_job, job.id, project_id, param_path, user.id)

‎backend/tests/test_api.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,12 @@ async def db_session():
4646
async def client(db_session):
4747
"""HTTP client with overridden DB dependency."""
4848
async def _override_db():
49-
yield db_session
49+
try:
50+
yield db_session
51+
await db_session.commit()
52+
except Exception:
53+
await db_session.rollback()
54+
raise
5055

5156
app.dependency_overrides[get_db] = _override_db
5257
transport = ASGITransport(app=app)
@@ -1783,15 +1788,14 @@ async def test_delete_nonexistent_job_returns_404(self, auth_client):
17831788
assert resp.status_code == 404
17841789

17851790
@pytest.mark.asyncio
1786-
async def test_get_job_status_returns_summary(self, auth_client):
1791+
async def test_get_job_status_returns_summary(self, auth_client, db_session):
17871792
"""Test get job status for a completed job returns summary fields."""
1788-
pid, x_fid, y_fid = await _create_project_with_datasets(auth_client)
1789-
job_id = await _run_mock_analysis(auth_client, pid, x_fid, y_fid)
1793+
pid, job_id = await _create_completed_job(auth_client, db_session)
17901794
resp = await auth_client.get(f"/api/analysis/{pid}/jobs/{job_id}")
17911795
assert resp.status_code == 200
17921796
data = resp.json()
17931797
assert data["job_id"] == job_id
1794-
assert data["status"] in ("completed", "pending")
1798+
assert data["status"] == "completed"
17951799
assert "config_hash" in data
17961800
assert "config_summary" in data
17971801

@@ -1840,10 +1844,9 @@ async def test_run_analysis_with_missing_file_returns_404(self, auth_client):
18401844
assert resp.status_code == 404
18411845

18421846
@pytest.mark.asyncio
1843-
async def test_get_job_detail_returns_features(self, auth_client):
1847+
async def test_get_job_detail_returns_features(self, auth_client, db_session):
18441848
"""Test that job detail returns feature names and best individual."""
1845-
pid, x_fid, y_fid = await _create_project_with_datasets(auth_client)
1846-
job_id = await _run_mock_analysis(auth_client, pid, x_fid, y_fid)
1849+
pid, job_id = await _create_completed_job(auth_client, db_session)
18471850
resp = await auth_client.get(f"/api/analysis/{pid}/jobs/{job_id}/detail")
18481851
assert resp.status_code == 200
18491852
data = resp.json()

‎frontend/src/components/ConsolePanel.vue‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
<span class="progress-gen">Gen {{ progress.generation }}<template v-if="progress.maxGen"> / {{ progress.maxGen }}</template></span>
99
<span class="progress-k" v-if="progress.k">k={{ progress.k }}</span>
1010
<span class="progress-lang" v-if="progress.language">{{ progress.language }}</span>
11+
<span class="progress-eta" v-if="etaDisplay">{{ etaDisplay }}</span>
1112
</div>
1213
<div class="progress-bar-wrap" v-if="progress.maxGen">
13-
<div class="progress-bar" :style="{ width: Math.min(100, progress.generation / progress.maxGen * 100) + '%' }"></div>
14+
<div class="progress-bar" :style="{ width: progressPct + '%' }"></div>
1415
</div>
1516
</template>
1617
<button class="close-btn" @click="$emit('close')" title="Minimize console">&#9660;</button>
@@ -33,7 +34,7 @@ const props = defineProps({
3334
jobId: { type: String, required: true },
3435
})
3536
36-
const emit = defineEmits(['close', 'completed', 'failed'])
37+
const emit = defineEmits(['close', 'completed', 'failed', 'progress'])
3738
3839
const logContent = ref('')
3940
const jobStatus = ref('pending')
@@ -44,6 +45,7 @@ let ws = null
4445
let wsConnected = false
4546
4647
let errorCount = 0
48+
let startTime = null // When we first see generation > 0
4749
4850
/* ── Progress parser ─────────────────────────────────── */
4951
// Parses generation lines like: "#42 | best: Ternary:Prevalence \t0 ████ 1 [k=55, age=0]"
@@ -70,6 +72,8 @@ function parseProgress(text) {
7072
progress.value.generation = lastGen
7173
progress.value.k = lastK
7274
progress.value.language = lastLang
75+
// Start time tracking on first generation seen
76+
if (!startTime) startTime = Date.now()
7377
}
7478
// Try to extract maxGen from early lines (config echo)
7579
if (!progress.value.maxGen) {
@@ -80,6 +84,48 @@ function parseProgress(text) {
8084
if (em) { progress.value.maxGen = parseInt(em[1]); break }
8185
}
8286
}
87+
// Emit progress data to parent for minimized bar display
88+
emitProgress()
89+
}
90+
91+
/* ── ETA calculation ─────────────────────────────────── */
92+
const progressPct = computed(() => {
93+
if (!progress.value.maxGen || progress.value.generation <= 0) return 0
94+
return Math.min(100, (progress.value.generation / progress.value.maxGen) * 100)
95+
})
96+
97+
const etaDisplay = computed(() => {
98+
if (!startTime || !progress.value.maxGen || progress.value.generation <= 0) return ''
99+
const elapsed = (Date.now() - startTime) / 1000 // seconds
100+
const pct = progress.value.generation / progress.value.maxGen
101+
if (pct >= 1) return ''
102+
const totalEstimate = elapsed / pct
103+
const remaining = Math.max(0, totalEstimate - elapsed)
104+
return formatEta(remaining)
105+
})
106+
107+
function formatEta(seconds) {
108+
if (seconds < 60) return `~${Math.ceil(seconds)}s left`
109+
if (seconds < 3600) {
110+
const m = Math.floor(seconds / 60)
111+
const s = Math.ceil(seconds % 60)
112+
return `~${m}m ${s}s left`
113+
}
114+
const h = Math.floor(seconds / 3600)
115+
const m = Math.ceil((seconds % 3600) / 60)
116+
return `~${h}h ${m}m left`
117+
}
118+
119+
function emitProgress() {
120+
emit('progress', {
121+
generation: progress.value.generation,
122+
maxGen: progress.value.maxGen,
123+
k: progress.value.k,
124+
language: progress.value.language,
125+
pct: progressPct.value,
126+
eta: etaDisplay.value,
127+
status: jobStatus.value,
128+
})
83129
}
84130
85131
/* ── ANSI → HTML converter ───────────────────────────── */
@@ -244,6 +290,7 @@ watch(() => props.jobId, (newId) => {
244290
logContent.value = ''
245291
jobStatus.value = 'pending'
246292
progress.value = { generation: 0, maxGen: 0, k: 0, language: '' }
293+
startTime = null
247294
stopConsole()
248295
startConsole()
249296
}
@@ -323,6 +370,11 @@ onUnmounted(stopConsole)
323370
color: var(--text-muted);
324371
font-size: 0.68rem;
325372
}
373+
.progress-eta {
374+
color: #e5c07b;
375+
font-size: 0.68rem;
376+
font-style: italic;
377+
}
326378
.progress-bar-wrap {
327379
width: 80px;
328380
height: 6px;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<template>
2+
<svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`" class="progress-circle">
3+
<!-- Background circle -->
4+
<circle
5+
:cx="center" :cy="center" :r="radius"
6+
fill="none"
7+
:stroke="bgColor"
8+
:stroke-width="strokeWidth"
9+
/>
10+
<!-- Progress arc -->
11+
<circle
12+
:cx="center" :cy="center" :r="radius"
13+
fill="none"
14+
:stroke="color"
15+
:stroke-width="strokeWidth"
16+
:stroke-dasharray="circumference"
17+
:stroke-dashoffset="dashOffset"
18+
stroke-linecap="round"
19+
class="progress-arc"
20+
/>
21+
<!-- Center text -->
22+
<text
23+
:x="center" :y="center"
24+
text-anchor="middle"
25+
dominant-baseline="central"
26+
:font-size="fontSize"
27+
:fill="textColor"
28+
font-weight="600"
29+
>
30+
{{ label || Math.round(pct) + '%' }}
31+
</text>
32+
</svg>
33+
</template>
34+
35+
<script setup>
36+
import { computed } from 'vue'
37+
38+
const props = defineProps({
39+
pct: { type: Number, default: 0 },
40+
size: { type: Number, default: 36 },
41+
strokeWidth: { type: Number, default: 3 },
42+
color: { type: String, default: '#00BFFF' },
43+
bgColor: { type: String, default: 'rgba(255,255,255,0.1)' },
44+
textColor: { type: String, default: '#dcdfe4' },
45+
label: { type: String, default: '' },
46+
})
47+
48+
const center = computed(() => props.size / 2)
49+
const radius = computed(() => (props.size - props.strokeWidth) / 2)
50+
const circumference = computed(() => 2 * Math.PI * radius.value)
51+
const dashOffset = computed(() => {
52+
const clampedPct = Math.min(100, Math.max(0, props.pct))
53+
return circumference.value * (1 - clampedPct / 100)
54+
})
55+
const fontSize = computed(() => Math.max(8, props.size * 0.28))
56+
</script>
57+
58+
<style scoped>
59+
.progress-circle {
60+
transform: rotate(-90deg);
61+
flex-shrink: 0;
62+
}
63+
.progress-arc {
64+
transition: stroke-dashoffset 0.4s ease;
65+
}
66+
/* Rotate text back so it's readable */
67+
.progress-circle text {
68+
transform: rotate(90deg);
69+
transform-origin: center;
70+
}
71+
</style>

‎frontend/src/views/ProjectDashboard.vue‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,18 @@
2727
</div>
2828
<div v-if="store.activeJobId" class="console-bottom" :class="{ minimized: !store.showConsole }">
2929
<div v-if="!store.showConsole" class="console-minimized-bar" @click="store.showConsole = true">
30+
<ProgressCircle
31+
v-if="jobProgress.pct > 0 && jobProgress.status === 'running'"
32+
:pct="jobProgress.pct"
33+
:size="28"
34+
:stroke-width="3"
35+
/>
3036
<span>Console</span>
3137
<span class="status-badge-mini" :class="miniStatus">{{ miniStatus }}</span>
38+
<template v-if="jobProgress.status === 'running' && jobProgress.generation > 0">
39+
<span class="mini-gen">Gen {{ jobProgress.generation }}<template v-if="jobProgress.maxGen"> / {{ jobProgress.maxGen }}</template></span>
40+
<span class="mini-eta" v-if="jobProgress.eta">{{ jobProgress.eta }}</span>
41+
</template>
3242
<span class="expand-icon">&#9650;</span>
3343
</div>
3444
<ConsolePanel
@@ -38,6 +48,7 @@
3848
@close="store.showConsole = false"
3949
@completed="onJobCompleted"
4050
@failed="onJobFailed"
51+
@progress="onProgress"
4152
/>
4253
</div>
4354
</div>
@@ -46,17 +57,21 @@
4657
</template>
4758

4859
<script setup>
49-
import { ref, computed, onMounted, watch } from 'vue'
60+
import { ref, reactive, computed, onMounted, watch } from 'vue'
5061
import { useRoute } from 'vue-router'
5162
import axios from 'axios'
5263
import { useProjectStore } from '../stores/project'
5364
import ConsolePanel from '../components/ConsolePanel.vue'
65+
import ProgressCircle from '../components/ProgressCircle.vue'
5466
import ShareModal from '../components/ShareModal.vue'
5567
import { requestPermission, notifyJobCompleted, notifyJobFailed } from '../utils/notify'
68+
import { useToast } from '../composables/useToast'
5669
5770
const route = useRoute()
5871
const store = useProjectStore()
72+
const { addToast } = useToast()
5973
const showShare = ref(false)
74+
const jobProgress = reactive({ generation: 0, maxGen: 0, k: 0, language: '', pct: 0, eta: '', status: 'pending' })
6075
6176
const projectId = computed(() => route.params.id)
6277
const project = computed(() => store.current)
@@ -73,24 +88,33 @@ async function loadProject() {
7388
7489
async function onJobCompleted(jobId) {
7590
await loadProject()
76-
// Send browser notification with AUC if available
91+
// Send browser notification + in-app toast with AUC if available
7792
try {
7893
const { data } = await axios.get(`/api/analysis/${projectId.value}/jobs/${jobId}`)
94+
const aucStr = data.best_auc != null ? ` — AUC ${Number(data.best_auc).toFixed(4)}` : ''
95+
const kStr = data.best_k != null ? ` (k=${data.best_k})` : ''
96+
addToast(`Job completed${aucStr}${kStr}`, 'success', 8000)
7997
notifyJobCompleted(project.value?.name || 'Project', {
8098
auc: data.best_auc,
8199
k: data.best_k,
82100
jobId,
83101
})
84102
} catch {
103+
addToast('Job completed', 'success', 6000)
85104
notifyJobCompleted(project.value?.name || 'Project', { jobId })
86105
}
87106
}
88107
89108
function onJobFailed(jobId) {
90109
loadProject()
110+
addToast('Job failed — check console for details', 'error', 8000)
91111
notifyJobFailed(project.value?.name || 'Project', { jobId })
92112
}
93113
114+
function onProgress(data) {
115+
Object.assign(jobProgress, data)
116+
}
117+
94118
watch(projectId, loadProject)
95119
onMounted(() => {
96120
loadProject()
@@ -226,6 +250,17 @@ onMounted(() => {
226250
.status-badge-mini.completed { background: var(--success-bg); color: var(--success-dark); }
227251
.status-badge-mini.failed { background: var(--danger-bg); color: var(--danger-dark); }
228252
253+
.mini-gen {
254+
font-size: 0.72rem;
255+
font-weight: 600;
256+
color: #00BFFF;
257+
}
258+
.mini-eta {
259+
font-size: 0.68rem;
260+
color: #e5c07b;
261+
font-style: italic;
262+
}
263+
229264
.loading {
230265
text-align: center;
231266
padding: 3rem;

0 commit comments

Comments
 (0)