Fix download button and API prefix issues #53
@@ -80,3 +80,31 @@ jobs:
|
|||||||
# Manually connect to networks after container is created
|
# Manually connect to networks after container is created
|
||||||
docker network connect nas-dashboard_internal nas-dashboard-dev || true
|
docker network connect nas-dashboard_internal nas-dashboard-dev || true
|
||||||
docker network connect gitea_gitea 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
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ jobs:
|
|||||||
--junit-xml=test-results.xml \
|
--junit-xml=test-results.xml \
|
||||||
--cov-fail-under=49
|
--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
|
- name: Upload coverage report
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -145,6 +152,13 @@ jobs:
|
|||||||
cd dashboard/frontend
|
cd dashboard/frontend
|
||||||
npm run test:coverage -- --reporter=verbose --run --pool=forks --poolOptions.forks.maxForks=2
|
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:
|
test-summary:
|
||||||
name: Test Summary
|
name: Test Summary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
|
|
||||||
from fastapi import Request
|
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_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
|
||||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ app.include_router(
|
|||||||
)
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
conversation_tracker.router,
|
conversation_tracker.router,
|
||||||
|
prefix="/api/conversations",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("conversations"))],
|
dependencies=[Depends(_inject_user), Depends(require_page("conversations"))],
|
||||||
)
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import aiosqlite
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import Optional
|
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")
|
CONVERSATION_TRACKER_DB = os.environ.get("CONVERSATION_TRACKER_DB", "/app/data/claude-code-tracker/conversations.db")
|
||||||
|
|
||||||
|
|||||||
@@ -287,3 +287,64 @@ def mock_request():
|
|||||||
request.client.host = "192.168.31.100"
|
request.client.host = "192.168.31.100"
|
||||||
request.headers = {}
|
request.headers = {}
|
||||||
return request
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -217,14 +217,16 @@ export async function download(path, filename) {
|
|||||||
|
|
||||||
const { token: downloadToken } = await tokenResponse.json();
|
const { token: downloadToken } = await tokenResponse.json();
|
||||||
|
|
||||||
// Use the token to trigger browser download (no memory loading)
|
// Use hidden iframe for download - avoids page navigation and popup blockers
|
||||||
const downloadUrl = `${BASE}/files/download?token=${downloadToken}`;
|
const iframe = document.createElement('iframe');
|
||||||
const a = document.createElement("a");
|
iframe.style.display = 'none';
|
||||||
a.href = downloadUrl;
|
iframe.src = `${BASE}/files/download?token=${downloadToken}`;
|
||||||
a.download = filename;
|
document.body.appendChild(iframe);
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
// Clean up iframe after download starts
|
||||||
document.body.removeChild(a);
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
}, 5000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Download error:", e);
|
console.error("Download error:", e);
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
dates = await get("/api/conversations/dates");
|
dates = await get("/conversations/dates");
|
||||||
if (dates.length) selectDate(dates[0]);
|
if (dates.length) selectDate(dates[0]);
|
||||||
loadStats();
|
loadStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -32,8 +32,8 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
summary = await get(`/api/conversations/summary/${d}`);
|
summary = await get(`/conversations/summary/${d}`);
|
||||||
conversations = await get(`/api/conversations/list?date=${d}`);
|
conversations = await get(`/conversations/list?date=${d}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
summary = null;
|
summary = null;
|
||||||
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
|
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
view = "detail";
|
view = "detail";
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
selectedConversation = await get(`/api/conversations/${sessionId}`);
|
selectedConversation = await get(`/conversations/${sessionId}`);
|
||||||
messages = await get(`/api/conversations/${sessionId}/messages`);
|
messages = await get(`/conversations/${sessionId}/messages`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = "Failed to load conversation";
|
error = "Failed to load conversation";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
view = "search";
|
view = "search";
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
searchResults = await get(`/api/conversations/search?q=${encodeURIComponent(searchQuery)}`);
|
searchResults = await get(`/conversations/search?q=${encodeURIComponent(searchQuery)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = "Search failed";
|
error = "Search failed";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
stats = await get("/api/conversations/stats");
|
stats = await get("/conversations/stats");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load stats", e);
|
console.error("Failed to load stats", e);
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
async function trigger() {
|
async function trigger() {
|
||||||
triggering = true;
|
triggering = true;
|
||||||
try {
|
try {
|
||||||
await post("/api/conversations/trigger");
|
await post("/conversations/trigger");
|
||||||
setTimeout(() => selectDate(selectedDate), 5000);
|
setTimeout(() => selectDate(selectedDate), 5000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = "Trigger failed";
|
error = "Trigger failed";
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
let currentPath = $state("");
|
let currentPath = $state("");
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let uploading = $state(false);
|
let uploading = $state(false);
|
||||||
let downloading = $state(false);
|
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
let fileInput;
|
let fileInput;
|
||||||
|
|
||||||
@@ -33,15 +32,12 @@
|
|||||||
|
|
||||||
async function handleDownload(name) {
|
async function handleDownload(name) {
|
||||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||||
downloading = true;
|
|
||||||
error = "";
|
error = "";
|
||||||
try {
|
try {
|
||||||
await download(path, name);
|
await download(path, name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = `Failed to download "${name}": ${e.message}`;
|
error = `Failed to download "${name}": ${e.message}`;
|
||||||
console.error("Download failed:", e);
|
console.error("Download failed:", e);
|
||||||
} finally {
|
|
||||||
downloading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +161,8 @@
|
|||||||
<span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
|
<span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
|
||||||
<div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
<div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||||||
{#if !e.is_dir}
|
{#if !e.is_dir}
|
||||||
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors disabled:opacity-50" disabled={downloading}>
|
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">
|
||||||
{downloading ? "..." : "DL"}
|
DL
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="text-[11px] text-rose-400 hover:text-rose-600 font-medium transition-colors" onclick={() => remove(e.name)}>Del</button>
|
<button class="text-[11px] text-rose-400 hover:text-rose-600 font-medium transition-colors" onclick={() => remove(e.name)}>Del</button>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { get, post } from '../../src/lib/api.js';
|
||||||
|
|
||||||
|
describe('Conversation Tracker API Paths', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock fetch globally
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
// Mock localStorage
|
||||||
|
global.localStorage = {
|
||||||
|
getItem: vi.fn(() => 'mock-token'),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call /api/conversations/dates without double prefix', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ['2026-04-19'],
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await get('/conversations/dates');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/conversations/dates'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/api/'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call /api/conversations/summary/:date correctly', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ summary: 'test' }),
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await get('/conversations/summary/2026-04-19');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/conversations/summary/2026-04-19'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/api/'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call /api/conversations/list correctly', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [],
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await get('/conversations/list?date=2026-04-19');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/conversations/list?date=2026-04-19'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call search endpoint correctly', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [],
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await get('/conversations/search?q=test');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/conversations/search?q=test'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call stats endpoint correctly', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ top_projects: [], top_tools: [] }),
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await get('/conversations/stats');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/conversations/stats'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call conversation detail endpoint correctly', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ session_id: 'test' }),
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await get('/conversations/test-session-id');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/conversations/test-session-id'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call trigger endpoint correctly', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: 'ok' }),
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await post('/conversations/trigger');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/conversations/trigger'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+360
@@ -0,0 +1,360 @@
|
|||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide covers the multi-layered testing approach for the NAS Dashboard to ensure features work correctly before and after deployment.
|
||||||
|
|
||||||
|
## Testing Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
/\
|
||||||
|
/E2E\ <- Full user workflows (optional)
|
||||||
|
/------\
|
||||||
|
/Smoke \ <- Post-deployment verification
|
||||||
|
/----------\
|
||||||
|
/Integration\ <- API and component integration
|
||||||
|
/--------------\
|
||||||
|
/ Unit Tests \ <- Fast, isolated tests
|
||||||
|
------------------
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests Locally
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dashboard/backend
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
pytest tests/ --cov=. --cov-report=term
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run specific test file:**
|
||||||
|
```bash
|
||||||
|
pytest tests/integration/test_conversation_tracker.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with coverage:**
|
||||||
|
```bash
|
||||||
|
pytest tests/ --cov=. --cov-report=html
|
||||||
|
open htmlcov/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dashboard/frontend
|
||||||
|
npm install
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run with coverage:**
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run in watch mode:**
|
||||||
|
```bash
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smoke Tests
|
||||||
|
|
||||||
|
Test dev deployment:
|
||||||
|
```bash
|
||||||
|
./scripts/smoke-test-dashboard.sh dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Test production deployment:
|
||||||
|
```bash
|
||||||
|
./scripts/smoke-test-dashboard.sh prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-Deployment Checklist
|
||||||
|
|
||||||
|
Before pushing code to dev branch:
|
||||||
|
|
||||||
|
- [ ] Run backend tests locally: `cd dashboard/backend && pytest tests/`
|
||||||
|
- [ ] Run frontend tests locally: `cd dashboard/frontend && npm run test`
|
||||||
|
- [ ] Verify API paths don't have double `/api/` prefixes
|
||||||
|
- [ ] Check router registration in `main.py` has correct prefix
|
||||||
|
- [ ] Test feature manually in local environment if possible
|
||||||
|
- [ ] Review code changes for obvious issues
|
||||||
|
|
||||||
|
## Post-Deployment Verification
|
||||||
|
|
||||||
|
### After Deployment to Dev
|
||||||
|
|
||||||
|
1. Wait for CI/CD to complete (check Gitea Actions)
|
||||||
|
2. Run smoke tests:
|
||||||
|
```bash
|
||||||
|
./scripts/smoke-test-dashboard.sh dev
|
||||||
|
```
|
||||||
|
3. Manually test the feature in browser at `http://nas.jimmygan.com:4001`
|
||||||
|
4. Check container logs for errors:
|
||||||
|
```bash
|
||||||
|
ssh nas "docker logs nas-dashboard-dev --tail 50"
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Deployment to Production
|
||||||
|
|
||||||
|
1. Run smoke tests:
|
||||||
|
```bash
|
||||||
|
./scripts/smoke-test-dashboard.sh prod
|
||||||
|
```
|
||||||
|
2. Verify health monitoring is active:
|
||||||
|
```bash
|
||||||
|
ssh nas "tail -20 /volume1/repos/nas-tools/logs/health-monitor.log"
|
||||||
|
```
|
||||||
|
3. Test critical user workflows manually
|
||||||
|
|
||||||
|
## Adding Tests for New Features
|
||||||
|
|
||||||
|
### 1. Backend API Tests
|
||||||
|
|
||||||
|
Create integration test in `dashboard/backend/tests/integration/`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
class TestMyNewFeature:
|
||||||
|
def test_endpoint_requires_auth(self, test_app):
|
||||||
|
response = test_app.get("/api/my-feature/endpoint")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_endpoint_with_auth(self, test_app, admin_headers):
|
||||||
|
response = test_app.get("/api/my-feature/endpoint", headers=admin_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Frontend API Path Tests
|
||||||
|
|
||||||
|
Create test in `dashboard/frontend/tests/integration/`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { get } from '../../src/lib/api.js';
|
||||||
|
|
||||||
|
describe('My Feature API Paths', () => {
|
||||||
|
it('should call correct API path', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
headers: new Headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await get('/my-feature/endpoint');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/my-feature/endpoint'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update Smoke Tests
|
||||||
|
|
||||||
|
Add checks to `scripts/smoke-test-dashboard.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test new feature endpoint
|
||||||
|
echo "Testing my new feature..."
|
||||||
|
RESPONSE=$(curl -s "$BASE_URL/api/my-feature/health")
|
||||||
|
if echo "$RESPONSE" | grep -q "ok"; then
|
||||||
|
echo "✅ My feature is working"
|
||||||
|
else
|
||||||
|
echo "❌ My feature failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
Tests run automatically on every push via `.gitea/workflows/test.yml`:
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
- Runs pytest with 49% minimum coverage
|
||||||
|
- Timeout: 30 seconds per test
|
||||||
|
- Generates JUnit XML and coverage reports
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
- Runs vitest with coverage
|
||||||
|
- Tests all components and API integrations
|
||||||
|
|
||||||
|
### Deployment Tests
|
||||||
|
- Smoke tests run after dev deployment
|
||||||
|
- Health checks verify container is healthy
|
||||||
|
- Logs checked for critical errors
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Check Monitoring
|
||||||
|
|
||||||
|
Runs every 5 minutes via cron on NAS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
*/5 * * * * /volume1/repos/nas-tools/scripts/monitor-dashboard-health.sh >> /volume1/repos/nas-tools/logs/health-monitor.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it checks:**
|
||||||
|
- Production dashboard `/api/health` endpoint
|
||||||
|
- Dev dashboard `/api/health` endpoint (non-critical)
|
||||||
|
- Sends Telegram alert on production failure
|
||||||
|
- Sends recovery notification when fixed
|
||||||
|
|
||||||
|
**View logs:**
|
||||||
|
```bash
|
||||||
|
ssh nas "tail -f /volume1/repos/nas-tools/logs/health-monitor.log"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check production
|
||||||
|
curl http://nas.jimmygan.com:4000/api/health
|
||||||
|
|
||||||
|
# Check dev
|
||||||
|
curl http://nas.jimmygan.com:4001/api/health
|
||||||
|
|
||||||
|
# Check container health
|
||||||
|
ssh nas "docker ps | grep nas-dashboard"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests Failing Locally
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
# Check Python version (should be 3.12+)
|
||||||
|
python --version
|
||||||
|
|
||||||
|
# Reinstall dependencies
|
||||||
|
cd dashboard/backend
|
||||||
|
rm -rf venv
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
# Clear node_modules and reinstall
|
||||||
|
cd dashboard/frontend
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smoke Tests Failing
|
||||||
|
|
||||||
|
1. Check if container is running:
|
||||||
|
```bash
|
||||||
|
ssh nas "docker ps | grep nas-dashboard"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check container logs:
|
||||||
|
```bash
|
||||||
|
ssh nas "docker logs nas-dashboard-dev --tail 100"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify network connectivity:
|
||||||
|
```bash
|
||||||
|
curl -v http://nas.jimmygan.com:4001/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Check if database is accessible (for conversation tracker):
|
||||||
|
```bash
|
||||||
|
ssh nas "ls -la /volume1/docker/claude-code-tracker/data/conversations.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Tests Failing
|
||||||
|
|
||||||
|
1. Check Gitea Actions logs in web UI
|
||||||
|
2. Look for specific test failures in output
|
||||||
|
3. Run the same tests locally to reproduce
|
||||||
|
4. Fix issues and push again
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
1. **Test behavior, not implementation** - Focus on what the API returns, not how it works internally
|
||||||
|
2. **Use descriptive test names** - `test_endpoint_requires_auth` is better than `test_1`
|
||||||
|
3. **One assertion per test** - Makes failures easier to diagnose
|
||||||
|
4. **Mock external dependencies** - Don't rely on real databases, APIs, or services
|
||||||
|
5. **Test error cases** - Not just the happy path
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
- **Aim for 50%+ coverage** - Current backend is at 49%
|
||||||
|
- **Focus on critical paths** - Auth, data access, user-facing features
|
||||||
|
- **Don't chase 100%** - Some code (like error handlers) is hard to test
|
||||||
|
|
||||||
|
### Deployment Safety
|
||||||
|
|
||||||
|
1. **Always test in dev first** - Never push directly to production
|
||||||
|
2. **Run smoke tests after deployment** - Automated verification catches issues
|
||||||
|
3. **Monitor logs after deployment** - Watch for errors in first 5-10 minutes
|
||||||
|
4. **Have rollback plan** - Know how to revert to previous version
|
||||||
|
|
||||||
|
## Common Issues and Solutions
|
||||||
|
|
||||||
|
### Issue: Double `/api/` prefix in API calls
|
||||||
|
|
||||||
|
**Symptom:** Frontend gets 404 errors, logs show `/api/api/endpoint`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Check router definition: should NOT have `prefix="/api/..."` in `APIRouter()`
|
||||||
|
- Check router registration: SHOULD have `prefix="/api/..."` in `app.include_router()`
|
||||||
|
- Frontend should call `get("/endpoint")` not `get("/api/endpoint")`
|
||||||
|
|
||||||
|
**Test:** Run `dashboard/frontend/tests/integration/conversations.test.js`
|
||||||
|
|
||||||
|
### Issue: Tests pass locally but fail in CI
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
- Different Python/Node versions
|
||||||
|
- Missing environment variables
|
||||||
|
- Timing issues (increase timeouts)
|
||||||
|
- File path differences
|
||||||
|
|
||||||
|
**Solution:** Check CI logs for specific error, replicate environment locally
|
||||||
|
|
||||||
|
### Issue: Smoke tests timeout
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
- Container not fully started
|
||||||
|
- Network issues
|
||||||
|
- Service crashed
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Check container status
|
||||||
|
ssh nas "docker ps -a | grep nas-dashboard"
|
||||||
|
|
||||||
|
# Check container logs
|
||||||
|
ssh nas "docker logs nas-dashboard-dev --tail 50"
|
||||||
|
|
||||||
|
# Restart container
|
||||||
|
ssh nas "cd /volume1/docker/nas-dashboard && docker compose -f docker-compose.dev.yml up -d"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **Backend Test Fixtures:** `dashboard/backend/tests/conftest.py`
|
||||||
|
- **Frontend Test Setup:** `dashboard/frontend/tests/setup.js`
|
||||||
|
- **CI/CD Workflows:** `.gitea/workflows/`
|
||||||
|
- **Smoke Tests:** `scripts/smoke-test-dashboard.sh`
|
||||||
|
- **Health Monitor:** `scripts/monitor-dashboard-health.sh`
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If tests are failing and you're stuck:
|
||||||
|
|
||||||
|
1. Check this documentation first
|
||||||
|
2. Look at existing tests for examples
|
||||||
|
3. Check CI/CD logs for specific errors
|
||||||
|
4. Review recent code changes that might have broken tests
|
||||||
|
5. Ask for help with specific error messages and context
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Monitor dashboard health and send alerts
|
||||||
|
# Run via cron: */5 * * * * /volume1/repos/nas-tools/scripts/monitor-dashboard-health.sh
|
||||||
|
|
||||||
|
PROD_URL="http://nas.jimmygan.com:4000"
|
||||||
|
DEV_URL="http://nas.jimmygan.com:4001"
|
||||||
|
ALERT_FILE="/tmp/dashboard-health-alert-sent"
|
||||||
|
LOG_DIR="/volume1/repos/nas-tools/logs"
|
||||||
|
|
||||||
|
# Create log directory if it doesn't exist
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
check_endpoint() {
|
||||||
|
local url=$1
|
||||||
|
local name=$2
|
||||||
|
|
||||||
|
response=$(curl -s -o /dev/null -w "%{http_code}" "$url/api/health" --max-time 5)
|
||||||
|
|
||||||
|
if [ "$response" != "200" ]; then
|
||||||
|
echo "$(date): ❌ $name health check failed (HTTP $response)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "$(date): ✅ $name health check passed"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check production
|
||||||
|
if ! check_endpoint "$PROD_URL" "Production"; then
|
||||||
|
if [ ! -f "$ALERT_FILE" ]; then
|
||||||
|
# Send alert only once until issue is resolved
|
||||||
|
/volume1/repos/nas-tools/notify.sh "⚠️ Dashboard Production Health Check Failed - HTTP endpoint not responding"
|
||||||
|
touch "$ALERT_FILE"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check dev (non-critical, just log)
|
||||||
|
if ! check_endpoint "$DEV_URL" "Dev"; then
|
||||||
|
echo "$(date): ⚠️ Dev dashboard health check failed (non-critical)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clear alert file if checks pass
|
||||||
|
if [ -f "$ALERT_FILE" ]; then
|
||||||
|
rm -f "$ALERT_FILE"
|
||||||
|
/volume1/repos/nas-tools/notify.sh "✅ Dashboard Production Health Restored"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$(date): ✅ All health checks passed"
|
||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Smoke test for dashboard deployment
|
||||||
|
# Usage: ./smoke-test-dashboard.sh [dev|prod]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
ENV=${1:-dev}
|
||||||
|
if [ "$ENV" = "dev" ]; then
|
||||||
|
BASE_URL="http://nas.jimmygan.com:4001"
|
||||||
|
CONTAINER="nas-dashboard-dev"
|
||||||
|
else
|
||||||
|
BASE_URL="http://nas.jimmygan.com:4000"
|
||||||
|
CONTAINER="nas-dashboard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Dashboard Smoke Test ($ENV) ==="
|
||||||
|
echo "Base URL: $BASE_URL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 1: Health check
|
||||||
|
echo "1. Testing health endpoint..."
|
||||||
|
HEALTH=$(curl -s "$BASE_URL/api/health" --max-time 5)
|
||||||
|
if echo "$HEALTH" | grep -q '"status":"ok"'; then
|
||||||
|
echo "✅ Health check passed"
|
||||||
|
else
|
||||||
|
echo "❌ Health check failed: $HEALTH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: Frontend loads
|
||||||
|
echo "2. Testing frontend loads..."
|
||||||
|
FRONTEND=$(curl -s "$BASE_URL/" --max-time 5 | grep -c "NAS Dashboard" || echo "0")
|
||||||
|
if [ "$FRONTEND" -gt 0 ]; then
|
||||||
|
echo "✅ Frontend loads"
|
||||||
|
else
|
||||||
|
echo "❌ Frontend failed to load"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: API endpoints respond (should require auth)
|
||||||
|
echo "3. Testing API endpoints require auth..."
|
||||||
|
DATES=$(curl -s "$BASE_URL/api/conversations/dates" --max-time 5)
|
||||||
|
if echo "$DATES" | grep -q "Not authenticated"; then
|
||||||
|
echo "✅ Conversation tracker API requires auth"
|
||||||
|
else
|
||||||
|
echo "❌ Unexpected response: $DATES"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 4: Check for double /api/ prefix bug
|
||||||
|
echo "4. Testing for double /api/ prefix bug..."
|
||||||
|
DOUBLE_API=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/api/conversations/dates" --max-time 5)
|
||||||
|
if [ "$DOUBLE_API" = "404" ]; then
|
||||||
|
echo "✅ No double /api/ prefix bug"
|
||||||
|
else
|
||||||
|
echo "⚠️ Warning: /api/api/ path returned $DOUBLE_API (should be 404)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 5: Container health
|
||||||
|
echo "5. Checking container health..."
|
||||||
|
HEALTH_STATUS=$(ssh nas "/volume1/@appstore/ContainerManager/usr/bin/docker inspect --format='{{.State.Health.Status}}' $CONTAINER" 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$HEALTH_STATUS" = "healthy" ]; then
|
||||||
|
echo "✅ Container is healthy"
|
||||||
|
else
|
||||||
|
echo "❌ Container health: $HEALTH_STATUS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 6: Check recent logs for errors
|
||||||
|
echo "6. Checking for recent errors in logs..."
|
||||||
|
ERROR_COUNT=$(ssh nas "/volume1/@appstore/ContainerManager/usr/bin/docker logs $CONTAINER --tail 50 2>&1 | grep -c 'ERROR' || echo 0")
|
||||||
|
if [ "$ERROR_COUNT" -lt 5 ]; then
|
||||||
|
echo "✅ No critical errors in recent logs ($ERROR_COUNT errors)"
|
||||||
|
else
|
||||||
|
echo "⚠️ Warning: Found $ERROR_COUNT errors in recent logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== All smoke tests passed! ==="
|
||||||
Reference in New Issue
Block a user