Skip to content

Commit e6d5b38

Browse files
eprifticlaude
andcommitted
Add live console output for analysis runs
Launch gpredomics in a subprocess (worker.py) to capture Rust stdout, stream logs via polling endpoint, and display in a terminal-style console in the results view. Auto-navigate to results after launching analysis. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 24b91c8 commit e6d5b38

4 files changed

Lines changed: 254 additions & 46 deletions

File tree

‎backend/app/routers/analysis.py‎

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,30 +86,70 @@ async def run_analysis(
8686

8787

8888
def _run_job(job_id: str, project_id: str, param_path: str) -> None:
89-
"""Execute gpredomics in a background thread. Uses its own DB session."""
89+
"""Execute gpredomics in a subprocess so we can capture Rust stdout."""
9090
import asyncio
91+
import subprocess
92+
import sys
9193

92-
async def _update_job():
94+
job_dir = Path(storage.settings.project_dir) / project_id / "jobs" / job_id
95+
log_path = job_dir / "console.log"
96+
results_path = job_dir / "results.json"
97+
98+
async def _execute():
99+
# Mark job as running
93100
async with async_session_factory() as db:
94101
result = await db.execute(select(Job).where(Job.id == job_id))
95102
job = result.scalar_one()
96103
job.status = "running"
97104
await db.commit()
98105

99106
try:
100-
results = engine.run_experiment(param_path)
101-
results_path = storage.save_job_result(project_id, job_id, results)
107+
if not engine.HAS_ENGINE:
108+
# Mock mode — no subprocess needed
109+
mock_results = engine._mock_results()
110+
storage.save_job_result(project_id, job_id, mock_results)
111+
with open(log_path, "w") as lf:
112+
lf.write("[mock] Engine not available, using mock results\n")
113+
114+
async with async_session_factory() as db:
115+
result = await db.execute(select(Job).where(Job.id == job_id))
116+
job = result.scalar_one()
117+
job.status = "completed"
118+
job.results_path = str(results_path)
119+
job.completed_at = datetime.now(timezone.utc)
120+
await db.commit()
121+
return
122+
123+
# Run worker subprocess — captures all Rust stdout
124+
worker_module = "app.services.worker"
125+
with open(log_path, "w") as lf:
126+
proc = subprocess.Popen(
127+
[sys.executable, "-m", worker_module, param_path, str(results_path)],
128+
stdout=lf,
129+
stderr=subprocess.STDOUT,
130+
cwd=str(Path(__file__).resolve().parents[2]), # backend/
131+
)
132+
proc.wait()
133+
134+
if proc.returncode != 0:
135+
error_msg = log_path.read_text()[-500:] if log_path.exists() else "Unknown error"
136+
raise RuntimeError(f"Worker exited with code {proc.returncode}: {error_msg}")
102137

103138
async with async_session_factory() as db:
104139
result = await db.execute(select(Job).where(Job.id == job_id))
105140
job = result.scalar_one()
106141
job.status = "completed"
107-
job.results_path = results_path
142+
job.results_path = str(results_path)
108143
job.completed_at = datetime.now(timezone.utc)
109144
await db.commit()
110145

111-
logger.info("Job %s completed: AUC=%.4f", job_id, results["best_individual"]["auc"])
146+
logger.info("Job %s completed", job_id)
147+
112148
except Exception as e:
149+
# Append error to log
150+
with open(log_path, "a") as lf:
151+
lf.write(f"\n[error] {e}\n")
152+
113153
async with async_session_factory() as db:
114154
result = await db.execute(select(Job).where(Job.id == job_id))
115155
job = result.scalar_one()
@@ -122,11 +162,42 @@ async def _update_job():
122162

123163
loop = asyncio.new_event_loop()
124164
try:
125-
loop.run_until_complete(_update_job())
165+
loop.run_until_complete(_execute())
126166
finally:
127167
loop.close()
128168

129169

170+
@router.get("/{project_id}/jobs/{job_id}/logs")
171+
async def get_job_logs(
172+
project_id: str,
173+
job_id: str,
174+
user: User = Depends(get_current_user),
175+
db: AsyncSession = Depends(get_db),
176+
):
177+
"""Get the console log output for a job."""
178+
result = await db.execute(
179+
select(Job).join(Project).where(
180+
Job.id == job_id,
181+
Job.project_id == project_id,
182+
Project.user_id == user.id,
183+
)
184+
)
185+
job = result.scalar_one_or_none()
186+
if not job:
187+
raise HTTPException(status_code=404, detail="Job not found")
188+
189+
log_path = Path(storage.settings.project_dir) / project_id / "jobs" / job_id / "console.log"
190+
log_content = ""
191+
if log_path.exists():
192+
log_content = log_path.read_text()
193+
194+
return {
195+
"job_id": job_id,
196+
"status": job.status,
197+
"log": log_content,
198+
}
199+
200+
130201
@router.get("/{project_id}/jobs/{job_id}", response_model=ExperimentSummary)
131202
async def get_job_status(
132203
project_id: str,

‎backend/app/services/worker.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Standalone worker script — runs gpredomicspy in a subprocess so stdout is capturable."""
2+
3+
import json
4+
import sys
5+
6+
7+
def main():
8+
param_path = sys.argv[1]
9+
results_path = sys.argv[2]
10+
11+
import gpredomicspy
12+
13+
param = gpredomicspy.Param()
14+
param.load(param_path)
15+
experiment = gpredomicspy.fit(param)
16+
17+
# Extract results
18+
best_pop = experiment.best_population()
19+
best = best_pop.best()
20+
metrics = dict(best.get_metrics())
21+
22+
results = {
23+
"fold_count": experiment.fold_count(),
24+
"generation_count": experiment.generation_count(),
25+
"execution_time": experiment.execution_time(),
26+
"feature_names": experiment.feature_names(),
27+
"sample_names": experiment.sample_names(),
28+
"best_individual": {
29+
"k": metrics["k"],
30+
"auc": metrics["auc"],
31+
"fit": metrics["fit"],
32+
"accuracy": metrics["accuracy"],
33+
"sensitivity": metrics["sensitivity"],
34+
"specificity": metrics["specificity"],
35+
"threshold": metrics["threshold"],
36+
"language": metrics["language"],
37+
"data_type": metrics["data_type"],
38+
"epoch": metrics["epoch"],
39+
"features": best.get_features(),
40+
},
41+
"population_size": len(best_pop),
42+
}
43+
44+
with open(results_path, "w") as f:
45+
json.dump(results, f, indent=2, default=str)
46+
47+
print(f"\n[worker] Results saved to {results_path}")
48+
49+
50+
if __name__ == "__main__":
51+
main()

‎frontend/src/views/ResultsView.vue‎

Lines changed: 122 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@
22
<div class="results">
33
<h2>Results</h2>
44

5-
<!-- Status -->
6-
<div v-if="loading" class="loading">Loading results...</div>
5+
<!-- Console output -->
6+
<section class="console-section" v-if="showConsole">
7+
<div class="console-header">
8+
<h3>Console Output</h3>
9+
<span class="status-badge" :class="jobStatus">{{ jobStatus }}</span>
10+
</div>
11+
<div class="console" ref="consoleEl">
12+
<pre>{{ logContent || 'Waiting for output...' }}</pre>
13+
</div>
14+
</section>
715

8-
<div v-else-if="!detail" class="status-card">
9-
<p><strong>Status:</strong> {{ summary?.status || 'unknown' }}</p>
10-
<button v-if="summary?.status === 'running' || summary?.status === 'pending'" @click="poll">
11-
Refresh
12-
</button>
13-
</div>
16+
<!-- Loading -->
17+
<div v-if="loading && !showConsole" class="loading">Loading results...</div>
1418

1519
<!-- Detail view -->
16-
<template v-else>
20+
<template v-if="detail">
1721
<div class="summary-grid">
1822
<div class="stat-card">
1923
<div class="stat-value">{{ detail.best_auc?.toFixed(4) || '—' }}</div>
@@ -68,14 +72,18 @@
6872
</template>
6973

7074
<script setup>
71-
import { ref, computed, onMounted } from 'vue'
75+
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
7276
import { useRoute } from 'vue-router'
7377
import axios from 'axios'
7478
7579
const route = useRoute()
7680
const loading = ref(true)
77-
const summary = ref(null)
7881
const detail = ref(null)
82+
const logContent = ref('')
83+
const jobStatus = ref('pending')
84+
const showConsole = ref(true)
85+
const consoleEl = ref(null)
86+
let pollTimer = null
7987
8088
const bestMetrics = computed(() => {
8189
if (!detail.value?.best_individual) return {}
@@ -100,27 +108,120 @@ function featureName(idx) {
100108
return `feature_${idx}`
101109
}
102110
103-
async function poll() {
111+
async function pollLogs() {
104112
const pid = route.params.id
105113
const jid = route.params.jobId
106114
try {
107-
const { data } = await axios.get(`/api/analysis/${pid}/jobs/${jid}`)
108-
summary.value = data
109-
if (data.status === 'completed') {
110-
const { data: d } = await axios.get(`/api/analysis/${pid}/jobs/${jid}/detail`)
111-
detail.value = d
115+
const { data } = await axios.get(`/api/analysis/${pid}/jobs/${jid}/logs`)
116+
logContent.value = data.log
117+
jobStatus.value = data.status
118+
119+
// Auto-scroll console
120+
await nextTick()
121+
if (consoleEl.value) {
122+
consoleEl.value.scrollTop = consoleEl.value.scrollHeight
123+
}
124+
125+
// If completed, fetch detail results and stop polling
126+
if (data.status === 'completed' || data.status === 'failed') {
127+
stopPolling()
128+
if (data.status === 'completed') {
129+
try {
130+
const { data: d } = await axios.get(`/api/analysis/${pid}/jobs/${jid}/detail`)
131+
detail.value = d
132+
} catch (e) {
133+
console.error('Failed to fetch detail:', e)
134+
}
135+
}
136+
loading.value = false
112137
}
113138
} catch (e) {
114-
console.error('Failed to fetch results:', e)
115-
} finally {
116-
loading.value = false
139+
console.error('Failed to poll logs:', e)
117140
}
118141
}
119142
120-
onMounted(poll)
143+
function startPolling() {
144+
pollLogs()
145+
pollTimer = setInterval(pollLogs, 1000)
146+
}
147+
148+
function stopPolling() {
149+
if (pollTimer) {
150+
clearInterval(pollTimer)
151+
pollTimer = null
152+
}
153+
}
154+
155+
onMounted(() => {
156+
loading.value = false
157+
startPolling()
158+
})
159+
160+
onUnmounted(stopPolling)
121161
</script>
122162
123163
<style scoped>
164+
.console-section {
165+
margin-bottom: 2rem;
166+
}
167+
168+
.console-header {
169+
display: flex;
170+
align-items: center;
171+
justify-content: space-between;
172+
margin-bottom: 0.5rem;
173+
}
174+
175+
.console-header h3 {
176+
color: #1a1a2e;
177+
}
178+
179+
.status-badge {
180+
padding: 0.25rem 0.75rem;
181+
border-radius: 12px;
182+
font-size: 0.8rem;
183+
font-weight: 600;
184+
text-transform: uppercase;
185+
}
186+
187+
.status-badge.pending {
188+
background: #fff3e0;
189+
color: #e65100;
190+
}
191+
192+
.status-badge.running {
193+
background: #e3f2fd;
194+
color: #1565c0;
195+
}
196+
197+
.status-badge.completed {
198+
background: #e8f5e9;
199+
color: #2e7d32;
200+
}
201+
202+
.status-badge.failed {
203+
background: #fce4ec;
204+
color: #c62828;
205+
}
206+
207+
.console {
208+
background: #1a1a2e;
209+
color: #e0e0e0;
210+
border-radius: 8px;
211+
padding: 1rem;
212+
max-height: 500px;
213+
overflow-y: auto;
214+
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
215+
font-size: 0.8rem;
216+
line-height: 1.5;
217+
}
218+
219+
.console pre {
220+
margin: 0;
221+
white-space: pre-wrap;
222+
word-break: break-all;
223+
}
224+
124225
.summary-grid {
125226
display: grid;
126227
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
@@ -208,23 +309,6 @@ onMounted(poll)
208309
color: #c62828;
209310
}
210311
211-
.status-card {
212-
background: white;
213-
padding: 2rem;
214-
border-radius: 8px;
215-
text-align: center;
216-
}
217-
218-
.status-card button {
219-
margin-top: 1rem;
220-
padding: 0.5rem 1.5rem;
221-
background: #1a1a2e;
222-
color: white;
223-
border: none;
224-
border-radius: 6px;
225-
cursor: pointer;
226-
}
227-
228312
.loading {
229313
text-align: center;
230314
padding: 3rem;

0 commit comments

Comments
 (0)