diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml
index 07f8037..cdc719e 100644
--- a/.gitea/workflows/deploy-dev.yml
+++ b/.gitea/workflows/deploy-dev.yml
@@ -80,3 +80,31 @@ jobs:
# Manually connect to networks after container is created
docker network connect nas-dashboard_internal nas-dashboard-dev || true
docker network connect gitea_gitea nas-dashboard-dev || true
+ - name: Wait for container to be healthy
+ run: |
+ echo "Waiting for container to be healthy..."
+ for i in {1..30}; do
+ STATUS=$(docker inspect --format='{{.State.Health.Status}}' nas-dashboard-dev 2>/dev/null || echo "starting")
+ if [ "$STATUS" = "healthy" ]; then
+ echo "Container is healthy"
+ break
+ fi
+ echo "Waiting... ($i/30) Status: $STATUS"
+ sleep 2
+ done
+ # Final check
+ STATUS=$(docker inspect --format='{{.State.Health.Status}}' nas-dashboard-dev 2>/dev/null || echo "unknown")
+ if [ "$STATUS" != "healthy" ]; then
+ echo "Container failed to become healthy: $STATUS"
+ docker logs nas-dashboard-dev --tail 50
+ exit 1
+ fi
+ - name: Run smoke tests
+ run: |
+ cd /volume1/repos/nas-tools
+ bash scripts/smoke-test-dashboard.sh dev
+ if [ $? -ne 0 ]; then
+ echo "Smoke tests failed! Check logs above."
+ docker logs nas-dashboard-dev --tail 100
+ exit 1
+ fi
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index 6e45b48..04375b9 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -72,6 +72,13 @@ jobs:
--junit-xml=test-results.xml \
--cov-fail-under=49
+ # Explicitly check exit code
+ if [ $? -ne 0 ]; then
+ echo "❌ Backend tests failed!"
+ exit 1
+ fi
+ echo "✅ Backend tests passed"
+
- name: Upload coverage report
if: always()
run: |
@@ -145,6 +152,13 @@ jobs:
cd dashboard/frontend
npm run test:coverage -- --reporter=verbose --run --pool=forks --poolOptions.forks.maxForks=2
+ # Explicitly check exit code
+ if [ $? -ne 0 ]; then
+ echo "❌ Frontend tests failed!"
+ exit 1
+ fi
+ echo "✅ Frontend tests passed"
+
test-summary:
name: Test Summary
runs-on: ubuntu-latest
diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py
index c038243..10f2526 100644
--- a/dashboard/backend/config.py
+++ b/dashboard/backend/config.py
@@ -3,7 +3,7 @@ import os
from fastapi import Request
-# Dashboard v1.3 — Runner DNS fix applied
+# Dashboard v1.4 — Conversation tracker enabled
GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py
index 4888eb0..6a69e4c 100644
--- a/dashboard/backend/main.py
+++ b/dashboard/backend/main.py
@@ -299,6 +299,7 @@ app.include_router(
)
app.include_router(
conversation_tracker.router,
+ prefix="/api/conversations",
dependencies=[Depends(_inject_user), Depends(require_page("conversations"))],
)
app.include_router(
diff --git a/dashboard/backend/routers/conversation_tracker.py b/dashboard/backend/routers/conversation_tracker.py
index e1012e5..07466f5 100644
--- a/dashboard/backend/routers/conversation_tracker.py
+++ b/dashboard/backend/routers/conversation_tracker.py
@@ -3,7 +3,7 @@ import aiosqlite
from fastapi import APIRouter, HTTPException, Query
from typing import Optional
-router = APIRouter(prefix="/api/conversations", tags=["conversations"])
+router = APIRouter(tags=["conversations"])
CONVERSATION_TRACKER_DB = os.environ.get("CONVERSATION_TRACKER_DB", "/app/data/claude-code-tracker/conversations.db")
diff --git a/dashboard/backend/tests/conftest.py b/dashboard/backend/tests/conftest.py
index de6d884..a51d5f6 100644
--- a/dashboard/backend/tests/conftest.py
+++ b/dashboard/backend/tests/conftest.py
@@ -287,3 +287,64 @@ def mock_request():
request.client.host = "192.168.31.100"
request.headers = {}
return request
+
+
+@pytest.fixture
+def mock_conversation_db(tmp_path, monkeypatch):
+ """Mock conversation tracker database"""
+ import sqlite3
+
+ db_path = tmp_path / "conversations.db"
+
+ # Create minimal SQLite schema
+ conn = sqlite3.connect(db_path)
+ conn.execute("""
+ CREATE TABLE conversations (
+ session_id TEXT PRIMARY KEY,
+ project_path TEXT,
+ started_at DATETIME,
+ message_count INTEGER,
+ total_input_tokens INTEGER DEFAULT 0,
+ total_output_tokens INTEGER DEFAULT 0
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ message_uuid TEXT UNIQUE NOT NULL,
+ role TEXT NOT NULL,
+ content_text TEXT,
+ timestamp DATETIME NOT NULL
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE tool_calls (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ message_uuid TEXT NOT NULL,
+ tool_name TEXT NOT NULL,
+ timestamp DATETIME NOT NULL
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE daily_summaries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ date TEXT UNIQUE NOT NULL,
+ summary_content TEXT NOT NULL
+ )
+ """)
+
+ # Insert test data
+ conn.execute("""
+ INSERT INTO conversations VALUES
+ ('test-session-id', '/test/project', '2026-04-19 10:00:00', 10, 1000, 500)
+ """)
+ conn.execute("""
+ INSERT INTO messages VALUES
+ (1, 'test-session-id', 'msg-1', 'user', 'test message', '2026-04-19 10:00:00')
+ """)
+ conn.commit()
+ conn.close()
+
+ monkeypatch.setenv("CONVERSATION_TRACKER_DB", str(db_path))
+ return db_path
diff --git a/dashboard/backend/tests/integration/test_conversation_tracker.py b/dashboard/backend/tests/integration/test_conversation_tracker.py
new file mode 100644
index 0000000..ad23ae4
--- /dev/null
+++ b/dashboard/backend/tests/integration/test_conversation_tracker.py
@@ -0,0 +1,81 @@
+import pytest
+from fastapi.testclient import TestClient
+
+
+class TestConversationTrackerAPI:
+ """Test conversation tracker endpoints"""
+
+ def test_list_dates_requires_auth(self, test_app):
+ """Verify dates endpoint requires authentication"""
+ response = test_app.get("/api/conversations/dates")
+ assert response.status_code == 401
+ assert "Not authenticated" in response.json()["detail"]
+
+ def test_list_dates_with_auth(self, test_app, admin_headers, mock_conversation_db):
+ """Verify dates endpoint returns data with auth"""
+ response = test_app.get("/api/conversations/dates", headers=admin_headers)
+ assert response.status_code == 200
+ assert isinstance(response.json(), list)
+
+ def test_get_summary_not_found(self, test_app, admin_headers, mock_conversation_db):
+ """Test daily summary endpoint returns 404 when no summary exists"""
+ response = test_app.get("/api/conversations/summary/2026-04-19", headers=admin_headers)
+ assert response.status_code == 404
+
+ def test_list_conversations(self, test_app, admin_headers, mock_conversation_db):
+ """Test conversation list endpoint"""
+ response = test_app.get("/api/conversations/list?date=2026-04-19", headers=admin_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert isinstance(data, list)
+ if len(data) > 0:
+ assert "session_id" in data[0]
+ assert "project_path" in data[0]
+
+ def test_list_conversations_requires_auth(self, test_app):
+ """Test conversation list requires authentication"""
+ response = test_app.get("/api/conversations/list?date=2026-04-19")
+ assert response.status_code == 401
+
+ def test_get_conversation_detail(self, test_app, admin_headers, mock_conversation_db):
+ """Test conversation detail endpoint"""
+ response = test_app.get("/api/conversations/test-session-id", headers=admin_headers)
+ assert response.status_code in [200, 404]
+
+ def test_get_conversation_messages(self, test_app, admin_headers, mock_conversation_db):
+ """Test conversation messages endpoint"""
+ response = test_app.get("/api/conversations/test-session-id/messages", headers=admin_headers)
+ assert response.status_code in [200, 404]
+
+ def test_search_conversations(self, test_app, admin_headers, mock_conversation_db):
+ """Test search endpoint"""
+ response = test_app.get("/api/conversations/search?q=test", headers=admin_headers)
+ assert response.status_code == 200
+ assert isinstance(response.json(), list)
+
+ def test_search_requires_query(self, test_app, admin_headers, mock_conversation_db):
+ """Test search requires query parameter"""
+ response = test_app.get("/api/conversations/search", headers=admin_headers)
+ # Should handle missing query gracefully
+ assert response.status_code in [200, 400, 422]
+
+ def test_get_stats(self, test_app, admin_headers, mock_conversation_db):
+ """Test stats endpoint"""
+ response = test_app.get("/api/conversations/stats", headers=admin_headers)
+ assert response.status_code == 200
+ data = response.json()
+ assert "top_projects" in data
+ assert "top_tools" in data
+ assert isinstance(data["top_projects"], list)
+ assert isinstance(data["top_tools"], list)
+
+ def test_trigger_summary_requires_auth(self, test_app):
+ """Test trigger endpoint requires authentication"""
+ response = test_app.post("/api/conversations/trigger")
+ assert response.status_code == 401
+
+ def test_api_prefix_correct(self, test_app, admin_headers):
+ """Test that API paths don't have double /api/ prefix"""
+ # This should be 404, not a valid endpoint
+ response = test_app.get("/api/api/conversations/dates", headers=admin_headers)
+ assert response.status_code == 404
diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js
index 864b0e0..1f0b2c7 100644
--- a/dashboard/frontend/src/lib/api.js
+++ b/dashboard/frontend/src/lib/api.js
@@ -217,14 +217,16 @@ export async function download(path, filename) {
const { token: downloadToken } = await tokenResponse.json();
- // Use the token to trigger browser download (no memory loading)
- const downloadUrl = `${BASE}/files/download?token=${downloadToken}`;
- const a = document.createElement("a");
- a.href = downloadUrl;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
+ // Use hidden iframe for download - avoids page navigation and popup blockers
+ const iframe = document.createElement('iframe');
+ iframe.style.display = 'none';
+ iframe.src = `${BASE}/files/download?token=${downloadToken}`;
+ document.body.appendChild(iframe);
+
+ // Clean up iframe after download starts
+ setTimeout(() => {
+ document.body.removeChild(iframe);
+ }, 5000);
} catch (e) {
console.error("Download error:", e);
throw e;
diff --git a/dashboard/frontend/src/routes/Conversations.svelte b/dashboard/frontend/src/routes/Conversations.svelte
index f9af7df..93cd15e 100644
--- a/dashboard/frontend/src/routes/Conversations.svelte
+++ b/dashboard/frontend/src/routes/Conversations.svelte
@@ -18,7 +18,7 @@
onMount(async () => {
try {
- dates = await get("/api/conversations/dates");
+ dates = await get("/conversations/dates");
if (dates.length) selectDate(dates[0]);
loadStats();
} catch (e) {
@@ -32,8 +32,8 @@
loading = true;
error = "";
try {
- summary = await get(`/api/conversations/summary/${d}`);
- conversations = await get(`/api/conversations/list?date=${d}`);
+ summary = await get(`/conversations/summary/${d}`);
+ conversations = await get(`/conversations/list?date=${d}`);
} catch (e) {
summary = null;
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
@@ -46,8 +46,8 @@
view = "detail";
loading = true;
try {
- selectedConversation = await get(`/api/conversations/${sessionId}`);
- messages = await get(`/api/conversations/${sessionId}/messages`);
+ selectedConversation = await get(`/conversations/${sessionId}`);
+ messages = await get(`/conversations/${sessionId}/messages`);
} catch (e) {
error = "Failed to load conversation";
} finally {
@@ -60,7 +60,7 @@
view = "search";
loading = true;
try {
- searchResults = await get(`/api/conversations/search?q=${encodeURIComponent(searchQuery)}`);
+ searchResults = await get(`/conversations/search?q=${encodeURIComponent(searchQuery)}`);
} catch (e) {
error = "Search failed";
} finally {
@@ -70,7 +70,7 @@
async function loadStats() {
try {
- stats = await get("/api/conversations/stats");
+ stats = await get("/conversations/stats");
} catch (e) {
console.error("Failed to load stats", e);
}
@@ -79,7 +79,7 @@
async function trigger() {
triggering = true;
try {
- await post("/api/conversations/trigger");
+ await post("/conversations/trigger");
setTimeout(() => selectDate(selectedDate), 5000);
} catch (e) {
error = "Trigger failed";
diff --git a/dashboard/frontend/src/routes/Files.svelte b/dashboard/frontend/src/routes/Files.svelte
index bb575ed..f4cead6 100644
--- a/dashboard/frontend/src/routes/Files.svelte
+++ b/dashboard/frontend/src/routes/Files.svelte
@@ -6,7 +6,6 @@
let currentPath = $state("");
let loading = $state(true);
let uploading = $state(false);
- let downloading = $state(false);
let error = $state("");
let fileInput;
@@ -33,15 +32,12 @@
async function handleDownload(name) {
const path = currentPath ? `${currentPath}/${name}` : name;
- downloading = true;
error = "";
try {
await download(path, name);
} catch (e) {
error = `Failed to download "${name}": ${e.message}`;
console.error("Download failed:", e);
- } finally {
- downloading = false;
}
}
@@ -165,8 +161,8 @@
{e.is_dir ? "—" : formatSize(e.size)}
{#if !e.is_dir}
-