diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 61aa178..bf5e5d9 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -51,6 +51,18 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} type=raw,value=${{ github.event.inputs.image_name }},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.image_name != '' }} + - name: Determine app version + id: app_version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.image_name }}" ]; then + version="${{ github.event.inputs.image_name }}" + elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then + version="${GITHUB_REF_NAME}" + else + version="sha-${GITHUB_SHA::7}" + fi + echo "value=${version}" >> "${GITHUB_OUTPUT}" + - name: Build and push Docker image uses: docker/build-push-action@v5 with: @@ -60,5 +72,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + APP_VERSION=${{ steps.app_version.outputs.value }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile.prod b/Dockerfile.prod index 35cf893..e4b69aa 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -44,6 +44,8 @@ RUN pip install --no-cache-dir -r requirements.txt # ============================================================================= FROM python:3.11-slim +ARG APP_VERSION=dev + WORKDIR /app # Install runtime dependencies @@ -62,6 +64,7 @@ ENV PATH="/opt/venv/bin:$PATH" ENV PYTHONUNBUFFERED=1 ENV UVICORN_WORKERS=1 ENV UVICORN_LOG_LEVEL=info +ENV APP_VERSION=${APP_VERSION} # Copy frontend build artifacts to nginx web root COPY --from=frontend-builder /app/frontend/dist /var/www/html diff --git a/entrypoint.sh b/entrypoint.sh index 52f06d6..6cbc289 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,19 +5,22 @@ set -e # This file is loaded by the browser before the main application API_URL_VALUE="${API_URL_EXTERNAL-/api}" WS_URL_VALUE="${WS_URL_EXTERNAL-/api/ws}" +APP_VERSION_VALUE="${APP_VERSION-dev}" cat > /var/www/html/env-config.js <}" echo " COORDINATOR_REALM: ${COORDINATOR_REALM:-realm1}" diff --git a/frontend/src/App.css b/frontend/src/App.css index b14c8e0..790c72d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -289,19 +289,49 @@ font-weight: 500; } -.coordinator-status { +.app-version { + font-size: 0.75rem; + letter-spacing: 0.04em; + opacity: 0.72; + white-space: nowrap; +} + +/* Connection Indicators */ +.connection-indicators { display: flex; align-items: center; - gap: 0.25rem; + gap: 1.5rem; } -.status-ok { - color: #22c55e; - font-weight: 500; +.connection-indicator { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + transition: opacity 0.2s ease; } -.status-error { - color: #ef4444; +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + transition: background-color 0.2s ease; +} + +.connection-indicator.connected .status-dot { + background-color: #22c55e; +} + +.connection-indicator.disconnected .status-dot { + background-color: #ef4444; +} + +.connection-indicator.reconnecting .status-dot { + background-color: #f59e0b; + animation: pulse 1s ease-in-out infinite; +} + +.connection-label { font-weight: 500; } @@ -370,4 +400,16 @@ .btn-retry:hover { background: #ef4444; } + + .connection-indicator.connected .status-dot { + background-color: #4ade80; + } + + .connection-indicator.disconnected .status-dot { + background-color: #f87171; + } + + .connection-indicator.reconnecting .status-dot { + background-color: #fbbf24; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25b2321..f3e2f92 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { useWebSocket } from "./hooks/useWebSocket"; import { LoadingSpinner, ErrorMessage, - ConnectionStatus, + ConnectionIndicators, } from "./components/common"; import { api } from "./services/api"; import type { @@ -20,6 +20,8 @@ import "./App.css"; * Main application component */ function App() { + const appVersion = + window.ENV?.APP_VERSION ?? import.meta.env.VITE_APP_VERSION ?? "dev"; const { presetGroups, loading, @@ -209,12 +211,6 @@ function App() {

Labgrid Dashboard

-
- -
@@ -261,16 +257,14 @@ function App() { {totalTargets} target{totalTargets !== 1 ? "s" : ""} found - {healthInfo && ( - - Coordinator:{" "} - {healthInfo.coordinator_connected ? ( - Connected - ) : ( - Disconnected - )} - - )} + + {appVersion} + + diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 3b0c956..74ac114 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -2,7 +2,7 @@ * Tests for the main App component. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import App from '../App'; @@ -81,6 +81,11 @@ vi.mock('../hooks/useWebSocket', () => ({ describe('App', () => { beforeEach(() => { vi.clearAllMocks(); + window.ENV = undefined; + }); + + afterEach(() => { + window.ENV = undefined; }); it('renders the app header', async () => { @@ -109,11 +114,12 @@ describe('App', () => { }); }); - it('shows connection status when connected', async () => { + it('shows footer connection indicators when connected', async () => { render(); await waitFor(() => { - expect(screen.getByText('Connected')).toBeInTheDocument(); + expect(screen.getByText('Backend')).toBeInTheDocument(); + expect(screen.getByText('Coordinator')).toBeInTheDocument(); }); }); @@ -126,4 +132,22 @@ describe('App', () => { expect(footerCount).toHaveTextContent('1 target found'); }); }); + + it('shows the application version in the footer', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('dev')).toBeInTheDocument(); + }); + }); + + it('uses the runtime application version when provided', async () => { + window.ENV = { APP_VERSION: 'v9.9.9-test' }; + + render(); + + await waitFor(() => { + expect(screen.getByText('v9.9.9-test')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/common/ConnectionIndicators.tsx b/frontend/src/components/common/ConnectionIndicators.tsx new file mode 100644 index 0000000..f40d48e --- /dev/null +++ b/frontend/src/components/common/ConnectionIndicators.tsx @@ -0,0 +1,38 @@ +interface ConnectionIndicatorsProps { + websocketConnected: boolean; + coordinatorConnected: boolean; + isReconnecting?: boolean; +} + +/** + * Connection status indicators with pictograms for footer + */ +export function ConnectionIndicators({ + websocketConnected, + coordinatorConnected, + isReconnecting = false, +}: ConnectionIndicatorsProps) { + return ( +
+ {/* Frontend ↔ Backend WebSocket Connection */} +
+ + Backend +
+ + {/* Backend ↔ Coordinator Connection */} +
+ + Coordinator +
+
+ ); +} + +export default ConnectionIndicators; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index bc7ea26..69ca8f7 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -1,3 +1,4 @@ export { LoadingSpinner } from './LoadingSpinner'; export { ErrorMessage } from './ErrorMessage'; export { ConnectionStatus } from './ConnectionStatus'; +export { ConnectionIndicators } from './ConnectionIndicators'; diff --git a/frontend/src/types/window-env.d.ts b/frontend/src/types/window-env.d.ts index 251a5a7..1e0bead 100644 --- a/frontend/src/types/window-env.d.ts +++ b/frontend/src/types/window-env.d.ts @@ -5,7 +5,7 @@ declare global { ENV?: { API_URL?: string; WS_URL?: string; + APP_VERSION?: string; }; } } -