From 227dd6eeb96fd16acefe58540e0464c40b7860c3 Mon Sep 17 00:00:00 2001 From: Sebastian Yde Madsen <75695915+seba2390@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:08:23 +0100 Subject: [PATCH 1/4] added todo --- TODO.md | 4 ++++ src/__init__.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..2c54ca5 --- /dev/null +++ b/TODO.md @@ -0,0 +1,4 @@ +# Todo in this version +- Create picture outline in view +- Create ability to rename a project +- Add tool to toolbar inside picture view that allows to change color of bounding box diff --git a/src/__init__.py b/src/__init__.py index e1c5ec7..22add86 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,4 +1,4 @@ """Bbannotate - Bounding box annotation tool for image datasets.""" -__version__ = "1.0.6" +__version__ = "1.0.7" __all__ = ["__version__"] From d13b5581c5a1203190dcda5f32a91bbe30358cf0 Mon Sep 17 00:00:00 2001 From: Sebastian Yde Madsen <75695915+seba2390@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:10:07 +0100 Subject: [PATCH 2/4] added todo items --- TODO.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TODO.md b/TODO.md index 2c54ca5..fe767bc 100644 --- a/TODO.md +++ b/TODO.md @@ -2,3 +2,5 @@ - Create picture outline in view - Create ability to rename a project - Add tool to toolbar inside picture view that allows to change color of bounding box +- Make sure that terminal can be closed after `bbannotate start` without disrupting program +- Make sure closing browser window stops all processes. From 52a3490a426710f652207cd3284a8750032487d8 Mon Sep 17 00:00:00 2001 From: Sebastian Yde Madsen <75695915+seba2390@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:17:36 +0100 Subject: [PATCH 3/4] feat: add high-contrast bbox color controls and browser-coupled server lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add canvas toolbar bbox color modes (auto/label/custom) with custom picker - use the selected color mode during live box drawing, not only after commit - implement fast auto-contrast color selection via per-image luminance integral map - strengthen selected annotation visibility on canvas and in the right sidebar list - run ╭───────────────────────── 🚀 Bbannotate ─────────────────────────╮ │ Starting bbannotate server │ │ │ │ URL: http://127.0.0.1:8000 │ │ Host: 127.0.0.1 │ │ Port: 8000 │ │ Reload: disabled │ │ Detached: enabled │ ╰─────────────────────────────────────────────────────────────────╯ ✓ Server started in background (PID: 5167) Browser session linked to server lifecycle. Closing the browser window will stop the background server. detached by default so terminal close does not stop the app - add token-based browser session heartbeat/close API and backend monitor to auto-shutdown on browser close - add/update CLI, API, and frontend tests for lifecycle and color behavior - split frontend bundles in Vite config to avoid oversized chunk warnings --- TODO.md | 7 +- frontend/src/App.tsx | 62 ++- .../components/canvas/AnnotationCanvas.tsx | 522 ++++++++++++++++-- .../src/components/ui/AnnotationList.test.tsx | 6 +- frontend/src/components/ui/AnnotationList.tsx | 16 +- frontend/src/components/ui/ProjectManager.tsx | 131 ++++- frontend/src/lib/api.test.ts | 37 +- frontend/src/lib/api.ts | 35 ++ frontend/src/test/mocks/handlers.ts | 31 +- frontend/src/types/index.ts | 8 + frontend/vite.config.ts | 11 + src/api/routes.py | 74 ++- src/cli.py | 179 ++++-- src/main.py | 66 +++ src/services/__init__.py | 3 +- src/services/browser_session_service.py | 104 ++++ src/services/project_service.py | 43 ++ tests/test_api.py | 97 ++++ tests/test_cli.py | 109 ++-- tests/test_project_service.py | 30 + 20 files changed, 1428 insertions(+), 143 deletions(-) create mode 100644 src/services/browser_session_service.py diff --git a/TODO.md b/TODO.md index fe767bc..38c349d 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,7 @@ # Todo in this version -- Create picture outline in view -- Create ability to rename a project -- Add tool to toolbar inside picture view that allows to change color of bounding box +- Create picture outline in view (DONE) +- Create ability to rename a project (DONE) +- Add tool to toolbar inside picture view that allows to change color of bounding box or better yet - automatically determine color that has high contrast compared to image. - Make sure that terminal can be closed after `bbannotate start` without disrupting program - Make sure closing browser window stops all processes. +- When pressing bb item in right hand side panel the highlighting of the selected bounding box on the image should be clearer (easier to see) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a919abe..5cf3d69 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,11 +19,15 @@ import { closeProject, markImageDone, getAllDoneStatus, + sendBrowserSessionHeartbeat, + sendBrowserSessionClose, } from '@/lib/api'; -import type { ToolMode, DrawingRect, BoundingBox, Project } from '@/types'; +import type { ToolMode, DrawingRect, BoundingBox, Project, BoundingBoxColorMode } from '@/types'; /** Default labels - empty so users define their own */ const DEFAULT_LABELS: string[] = []; +const DEFAULT_CUSTOM_BBOX_COLOR = '#22c55e'; +const BROWSER_SESSION_HEARTBEAT_MS = 2500; /** Get the localStorage key for a project's labels */ function getLabelsKey(projectName: string | null): string { @@ -64,6 +68,19 @@ function saveLabelsForProject(projectName: string | null, labels: string[]): voi function App(): JSX.Element { const [currentProject, setCurrentProject] = useState(null); const [toolMode, setToolMode] = useState('draw'); + const [bboxColorMode, setBboxColorMode] = useState(() => { + if (typeof window === 'undefined') return 'auto'; + const saved = localStorage.getItem('bboxColorMode'); + if (saved === 'label' || saved === 'auto' || saved === 'custom') { + return saved; + } + return 'auto'; + }); + const [customBboxColor, setCustomBboxColor] = useState(() => { + if (typeof window === 'undefined') return DEFAULT_CUSTOM_BBOX_COLOR; + const saved = localStorage.getItem('customBboxColor'); + return saved ?? DEFAULT_CUSTOM_BBOX_COLOR; + }); const [labels, setLabels] = useState(DEFAULT_LABELS); const [currentLabel, setCurrentLabel] = useState(''); const [showLabelManager, setShowLabelManager] = useState(false); @@ -182,6 +199,45 @@ function App(): JSX.Element { } }, [darkMode]); + // Persist bounding box color preferences + useEffect(() => { + localStorage.setItem('bboxColorMode', bboxColorMode); + }, [bboxColorMode]); + + useEffect(() => { + localStorage.setItem('customBboxColor', customBboxColor); + }, [customBboxColor]); + + // Keep detached server alive while browser session is active + useEffect(() => { + const sessionToken = new URLSearchParams(window.location.search).get('bb_session'); + if (!sessionToken) { + return; + } + + const sendHeartbeat = (): void => { + void sendBrowserSessionHeartbeat(sessionToken).catch(() => { + // Best-effort lifecycle signal - safe to ignore transient failures + }); + }; + + const handleCloseSignal = (): void => { + sendBrowserSessionClose(sessionToken); + }; + + sendHeartbeat(); + const intervalId = window.setInterval(sendHeartbeat, BROWSER_SESSION_HEARTBEAT_MS); + window.addEventListener('pagehide', handleCloseSignal); + window.addEventListener('beforeunload', handleCloseSignal); + + return () => { + window.clearInterval(intervalId); + window.removeEventListener('pagehide', handleCloseSignal); + window.removeEventListener('beforeunload', handleCloseSignal); + handleCloseSignal(); + }; + }, []); + // Show errors from hooks as toasts useEffect(() => { if (imagesError) { @@ -630,6 +686,8 @@ function App(): JSX.Element { annotations={annotations} selectedId={selectedId} toolMode={toolMode} + bboxColorMode={bboxColorMode} + customBboxColor={customBboxColor} currentLabel={currentLabel} currentClassId={labels.indexOf(currentLabel)} labels={labels} @@ -639,6 +697,8 @@ function App(): JSX.Element { onUpdateBbox={handleUpdateBbox} onDeleteAnnotation={handleDeleteAnnotation} onToolModeChange={setToolMode} + onBboxColorModeChange={setBboxColorMode} + onCustomBboxColorChange={setCustomBboxColor} onMarkDone={handleMarkDone} onLabelChange={setCurrentLabel} /> diff --git a/frontend/src/components/canvas/AnnotationCanvas.tsx b/frontend/src/components/canvas/AnnotationCanvas.tsx index 5af5d46..e4aa0ad 100644 --- a/frontend/src/components/canvas/AnnotationCanvas.tsx +++ b/frontend/src/components/canvas/AnnotationCanvas.tsx @@ -1,19 +1,126 @@ -import { useRef, useState, useEffect, useCallback } from 'react'; -import { Stage, Layer, Image as KonvaImage, Rect, Transformer, Group, Text } from 'react-konva'; +import { useRef, useState, useEffect, useCallback, useMemo } from 'react'; +import { + Stage, + Layer, + FastLayer, + Image as KonvaImage, + Rect, + Transformer, + Group, + Text, + Line, +} from 'react-konva'; import type Konva from 'konva'; -import type { Annotation, BoundingBox, DrawingRect, ToolMode } from '@/types'; +import type { Annotation, BoundingBox, BoundingBoxColorMode, DrawingRect, ToolMode } from '@/types'; import { getLabelColor } from '@/lib/constants'; /** Edge pan threshold in pixels (distance from edge to trigger auto-pan) */ const EDGE_PAN_THRESHOLD = 15; /** Auto-pan speed in pixels per frame */ const EDGE_PAN_SPEED = 4; +/** Minimum corner accent size for image outline */ +const IMAGE_FRAME_MIN_CORNER = 18; +/** Maximum corner accent size for image outline */ +const IMAGE_FRAME_MAX_CORNER = 44; +/** Dynamic corner sizing factor based on shortest image side */ +const IMAGE_FRAME_CORNER_RATIO = 0.08; + +const IMAGE_FRAME_COLORS = { + outer: 'rgba(14, 165, 233, 0.55)', // primary-500 + inner: 'rgba(248, 250, 252, 0.72)', // slate-50 + corner: 'rgba(236, 72, 153, 0.75)', // pink-500 +} as const; + +const AUTO_CONTRAST_COLOR_PALETTE = [ + '#22c55e', + '#0ea5e9', + '#f97316', + '#e11d48', + '#2563eb', + '#14b8a6', + '#f59e0b', + '#9333ea', +] as const; +const AUTO_CONTRAST_SAMPLE_LIMIT = 256; + +interface LuminanceIntegralMap { + width: number; + height: number; + integral: Float32Array; +} + +interface RenderAnnotation { + annotation: Annotation; + rect: DrawingRect; + color: string; + selected: boolean; +} + +interface RgbColor { + r: number; + g: number; + b: number; +} + +const AUTO_PALETTE_LUMINANCE = AUTO_CONTRAST_COLOR_PALETTE.map((color) => + getRelativeLuminance(color) +); + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function parseHexColor(hex: string): RgbColor { + const normalized = hex.startsWith('#') ? hex.slice(1) : hex; + const sixDigit = + normalized.length === 3 + ? `${normalized[0]}${normalized[0]}${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}` + : normalized; + const intValue = Number.parseInt(sixDigit, 16); + return { + r: (intValue >> 16) & 255, + g: (intValue >> 8) & 255, + b: intValue & 255, + }; +} + +function linearizeChannel(value: number): number { + const normalized = value / 255; + return normalized <= 0.04045 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4); +} + +function getRelativeLuminance(hexColor: string): number { + const rgb = parseHexColor(hexColor); + const r = linearizeChannel(rgb.r); + const g = linearizeChannel(rgb.g); + const b = linearizeChannel(rgb.b); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function getContrastRatio(luminanceA: number, luminanceB: number): number { + const light = Math.max(luminanceA, luminanceB); + const dark = Math.min(luminanceA, luminanceB); + return (light + 0.05) / (dark + 0.05); +} + +function normalizeRect(rect: DrawingRect): DrawingRect { + const x = rect.width < 0 ? rect.x + rect.width : rect.x; + const y = rect.height < 0 ? rect.y + rect.height : rect.y; + return { + x, + y, + width: Math.abs(rect.width), + height: Math.abs(rect.height), + }; +} interface AnnotationCanvasProps { imageUrl: string | null; annotations: Annotation[]; selectedId: string | null; toolMode: ToolMode; + bboxColorMode: BoundingBoxColorMode; + customBboxColor: string; currentLabel: string; currentClassId: number; labels: string[]; @@ -23,6 +130,8 @@ interface AnnotationCanvasProps { onUpdateBbox: (annotationId: string, bbox: BoundingBox) => void; onDeleteAnnotation: (annotationId: string) => void; onToolModeChange: (mode: ToolMode) => void; + onBboxColorModeChange: (mode: BoundingBoxColorMode) => void; + onCustomBboxColorChange: (color: string) => void; onMarkDone: () => void; onLabelChange: (label: string) => void; } @@ -35,6 +144,8 @@ export function AnnotationCanvas({ annotations, selectedId, toolMode, + bboxColorMode, + customBboxColor, currentLabel, labels, isCurrentImageDone, @@ -43,6 +154,8 @@ export function AnnotationCanvas({ onUpdateBbox, onDeleteAnnotation: _onDeleteAnnotation, // Used externally via keyboard shortcuts in App.tsx onToolModeChange, + onBboxColorModeChange, + onCustomBboxColorChange, onMarkDone, onLabelChange, }: AnnotationCanvasProps): JSX.Element { @@ -56,6 +169,7 @@ export function AnnotationCanvas({ const [baseScale, setBaseScale] = useState(1); const [isDrawing, setIsDrawing] = useState(false); const [drawingRect, setDrawingRect] = useState(null); + const [luminanceMap, setLuminanceMap] = useState(null); // Canvas zoom and pan state const [zoom, setZoom] = useState(1); @@ -82,6 +196,51 @@ export function AnnotationCanvas({ }; }, [imageUrl]); + // Build luminance integral map once per image for O(1) bbox contrast lookup + useEffect(() => { + if (!image) { + setLuminanceMap(null); + return; + } + + const sampleWidth = Math.max(1, Math.min(AUTO_CONTRAST_SAMPLE_LIMIT, image.width)); + const sampleHeight = Math.max(1, Math.min(AUTO_CONTRAST_SAMPLE_LIMIT, image.height)); + const canvas = document.createElement('canvas'); + canvas.width = sampleWidth; + canvas.height = sampleHeight; + const context = canvas.getContext('2d'); + + if (!context) { + setLuminanceMap(null); + return; + } + + context.drawImage(image, 0, 0, sampleWidth, sampleHeight); + const imageData = context.getImageData(0, 0, sampleWidth, sampleHeight); + const pixels = imageData.data; + const stride = sampleWidth + 1; + const integral = new Float32Array((sampleWidth + 1) * (sampleHeight + 1)); + + for (let y = 1; y <= sampleHeight; y += 1) { + let rowSum = 0; + for (let x = 1; x <= sampleWidth; x += 1) { + const offset = ((y - 1) * sampleWidth + (x - 1)) * 4; + const r = pixels[offset] ?? 0; + const g = pixels[offset + 1] ?? 0; + const b = pixels[offset + 2] ?? 0; + rowSum += (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; + const previousIntegral = integral[(y - 1) * stride + x] ?? 0; + integral[y * stride + x] = previousIntegral + rowSum; + } + } + + setLuminanceMap({ + width: sampleWidth, + height: sampleHeight, + integral, + }); + }, [image]); + // Resize stage to fit container useEffect(() => { const updateSize = (): void => { @@ -234,6 +393,105 @@ export function AnnotationCanvas({ [image] ); + const getRectAverageLuminance = useCallback( + (rect: DrawingRect): number => { + if (!image || !luminanceMap) return 0.5; + const sampleWidth = luminanceMap.width; + const sampleHeight = luminanceMap.height; + if (sampleWidth <= 0 || sampleHeight <= 0) return 0.5; + + const x1 = clamp(Math.floor((rect.x / image.width) * sampleWidth), 0, sampleWidth - 1); + const y1 = clamp(Math.floor((rect.y / image.height) * sampleHeight), 0, sampleHeight - 1); + const x2 = clamp( + Math.ceil(((rect.x + rect.width) / image.width) * sampleWidth), + x1 + 1, + sampleWidth + ); + const y2 = clamp( + Math.ceil(((rect.y + rect.height) / image.height) * sampleHeight), + y1 + 1, + sampleHeight + ); + + const stride = sampleWidth + 1; + const integral = luminanceMap.integral; + const area = (x2 - x1) * (y2 - y1); + if (area <= 0) return 0.5; + + const sum = + (integral[y2 * stride + x2] ?? 0) - + (integral[y1 * stride + x2] ?? 0) - + (integral[y2 * stride + x1] ?? 0) + + (integral[y1 * stride + x1] ?? 0); + + return sum / area; + }, + [image, luminanceMap] + ); + + const getAutoContrastColor = useCallback( + (rect: DrawingRect): string => { + const backgroundLuminance = getRectAverageLuminance(rect); + let bestColor: string = AUTO_CONTRAST_COLOR_PALETTE[0]; + let bestContrast = 0; + + for (let i = 0; i < AUTO_CONTRAST_COLOR_PALETTE.length; i += 1) { + const paletteColor = AUTO_CONTRAST_COLOR_PALETTE[i] ?? AUTO_CONTRAST_COLOR_PALETTE[0]; + const colorLuminance = AUTO_PALETTE_LUMINANCE[i] ?? 0; + const contrast = getContrastRatio(colorLuminance, backgroundLuminance); + if (contrast > bestContrast) { + bestContrast = contrast; + bestColor = paletteColor; + } + } + + return bestColor; + }, + [getRectAverageLuminance] + ); + + const resolveAnnotationColor = useCallback( + (annotation: Annotation, rect: DrawingRect): string => { + if (bboxColorMode === 'custom') { + return customBboxColor; + } + if (bboxColorMode === 'auto') { + return getAutoContrastColor(rect); + } + return getLabelColor(annotation.label); + }, + [bboxColorMode, customBboxColor, getAutoContrastColor] + ); + + const drawingPreviewColor = useMemo((): string => { + if (!drawingRect) { + return '#22c55e'; + } + + if (bboxColorMode === 'custom') { + return customBboxColor; + } + + if (bboxColorMode === 'label') { + const activeLabel = currentLabel || labels[0] || 'default'; + return getLabelColor(activeLabel); + } + + return getAutoContrastColor(normalizeRect(drawingRect)); + }, [drawingRect, bboxColorMode, customBboxColor, currentLabel, labels, getAutoContrastColor]); + + const renderedAnnotations = useMemo(() => { + return annotations.map((annotation) => { + const rect = bboxToRect(annotation.bbox); + return { + annotation, + rect, + color: resolveAnnotationColor(annotation, rect), + selected: annotation.id === selectedId, + }; + }); + }, [annotations, bboxToRect, resolveAnnotationColor, selectedId]); + // Convert pointer position from stage space to image space const getImagePosition = useCallback( (stage: Konva.Stage): { x: number; y: number } | null => { @@ -523,6 +781,13 @@ export function AnnotationCanvas({ ); } + const imageWidth = image?.width ?? 0; + const imageHeight = image?.height ?? 0; + const cornerLength = Math.max( + IMAGE_FRAME_MIN_CORNER, + Math.min(IMAGE_FRAME_MAX_CORNER, Math.min(imageWidth, imageHeight) * IMAGE_FRAME_CORNER_RATIO) + ); + return (
)} + +
+ +
+ + Box + +
+ {(['auto', 'label', 'custom'] as const).map((mode) => ( + + ))} +
+ + {bboxColorMode === 'custom' && ( + + )} +
- {image && } + + {image && ( + <> + + {/* Dual-tone frame improves edge visibility on light and dark images */} + + + {/* Corner accents keep edges readable even when backgrounds are busy */} + + + + + + + + + + )} + - {annotations.map((ann) => { - const rect = bboxToRect(ann.bbox); - const color = getLabelColor(ann.label); - const isSelected = ann.id === selectedId; - - return ( - - handleRectClick(e, ann.id)} - onTap={(e) => handleRectClick(e, ann.id)} - onDragEnd={(e) => handleDragEnd(e, ann.id)} - onTransformEnd={(e) => handleTransformEnd(e, ann.id)} - /> - - - ); - })} + {renderedAnnotations.map(({ annotation, rect, color, selected }) => ( + + {selected && ( + <> + + + + )} + handleRectClick(e, annotation.id)} + onTap={(e) => handleRectClick(e, annotation.id)} + onDragEnd={(e) => handleDragEnd(e, annotation.id)} + onTransformEnd={(e) => handleTransformEnd(e, annotation.id)} + /> + + + ))} {/* Drawing rectangle */} {drawingRect && ( )} {/* Transformer for resizing */} diff --git a/frontend/src/components/ui/AnnotationList.test.tsx b/frontend/src/components/ui/AnnotationList.test.tsx index 0005aaa..41dcd56 100644 --- a/frontend/src/components/ui/AnnotationList.test.tsx +++ b/frontend/src/components/ui/AnnotationList.test.tsx @@ -68,9 +68,9 @@ describe('AnnotationList', () => { it('should highlight selected annotation', () => { const { container } = render(); - // The selected annotation should have ring-2 class - const items = container.querySelectorAll('[class*="ring-2"]'); - expect(items).toHaveLength(1); + const selectedItem = container.querySelector('[aria-selected="true"]'); + expect(selectedItem).not.toBeNull(); + expect(selectedItem?.className).toContain('ring-2'); }); it('should render color indicators for each annotation', () => { diff --git a/frontend/src/components/ui/AnnotationList.tsx b/frontend/src/components/ui/AnnotationList.tsx index c838c52..d5f3a04 100644 --- a/frontend/src/components/ui/AnnotationList.tsx +++ b/frontend/src/components/ui/AnnotationList.tsx @@ -33,13 +33,23 @@ export function AnnotationList({
onSelectAnnotation(ann.id)} + aria-selected={selectedId === ann.id} > -
+

{index + 1}. {ann.label} diff --git a/frontend/src/components/ui/ProjectManager.tsx b/frontend/src/components/ui/ProjectManager.tsx index e795c3d..5befb5f 100644 --- a/frontend/src/components/ui/ProjectManager.tsx +++ b/frontend/src/components/ui/ProjectManager.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import type { Project } from '@/types'; -import { listProjects, createProject, deleteProject, openProject } from '@/lib/api'; +import { listProjects, createProject, deleteProject, openProject, renameProject } from '@/lib/api'; import { ConfirmDialog } from './ConfirmDialog'; interface ProjectManagerProps { @@ -14,9 +14,12 @@ export function ProjectManager({ onOpenProject }: ProjectManagerProps): JSX.Elem const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); + const [renaming, setRenaming] = useState(false); const [newProjectName, setNewProjectName] = useState(''); + const [renameProjectName, setRenameProjectName] = useState(''); const [error, setError] = useState(null); const [projectToDelete, setProjectToDelete] = useState(null); + const [projectToRename, setProjectToRename] = useState(null); const loadProjects = useCallback(async (): Promise => { try { @@ -65,6 +68,44 @@ export function ProjectManager({ onOpenProject }: ProjectManagerProps): JSX.Elem setProjectToDelete(project); }; + const handleRenameProject = (project: Project): void => { + setProjectToRename(project); + setRenameProjectName(project.name); + }; + + const closeRenameDialog = useCallback((): void => { + if (renaming) return; + setProjectToRename(null); + setRenameProjectName(''); + }, [renaming]); + + const confirmRenameProject = useCallback(async (): Promise => { + if (!projectToRename) return; + + const trimmed = renameProjectName.trim(); + if (!trimmed) { + setError('Project name cannot be empty'); + return; + } + + if (trimmed === projectToRename.name) { + closeRenameDialog(); + return; + } + + try { + setRenaming(true); + await renameProject(projectToRename.id, { name: trimmed }); + await loadProjects(); + setProjectToRename(null); + setRenameProjectName(''); + } catch { + setError('Failed to rename project'); + } finally { + setRenaming(false); + } + }, [projectToRename, renameProjectName, closeRenameDialog, loadProjects]); + const confirmDeleteProject = async (): Promise => { if (!projectToDelete) return; @@ -187,6 +228,25 @@ export function ProjectManager({ onOpenProject }: ProjectManagerProps): JSX.Elem Last opened: {formatDate(project.last_opened)}

+
+ {/* Rename project dialog */} + {projectToRename && ( +
+
e.stopPropagation()} + > +
+

+ Rename Project +

+ +
+
+ + setRenameProjectName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && void confirmRenameProject()} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + maxLength={100} + autoFocus + disabled={renaming} + /> +
+
+ + +
+
+
+ )} + {/* Delete confirmation dialog */} { + describe('Browser Session', () => { + it('should send browser session heartbeat', async () => { + await expect(api.sendBrowserSessionHeartbeat('session-token')).resolves.not.toThrow(); + }); + + it('should use sendBeacon when available for close signal', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon'); + const sendBeacon = vi.fn().mockReturnValue(true); + Object.defineProperty(navigator, 'sendBeacon', { + configurable: true, + value: sendBeacon, + }); + + api.sendBrowserSessionClose('session-token'); + + expect(sendBeacon).toHaveBeenCalledTimes(1); + expect(sendBeacon).toHaveBeenCalledWith('/api/session/close', expect.any(Blob)); + + if (originalDescriptor) { + Object.defineProperty(navigator, 'sendBeacon', originalDescriptor); + } else { + Object.defineProperty(navigator, 'sendBeacon', { + configurable: true, + value: undefined, + }); + } + }); + }); + describe('Project Management', () => { it('should list all projects', async () => { const projects = await api.listProjects(); @@ -38,6 +67,12 @@ describe('API Client', () => { it('should delete a project', async () => { await expect(api.deleteProject('project-1')).resolves.not.toThrow(); }); + + it('should rename a project', async () => { + const updated = await api.renameProject('project-1', { name: 'Renamed Project' }); + expect(updated.id).toBe('project-1'); + expect(updated.name).toBe('Renamed Project'); + }); }); describe('Project Info', () => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dc335d4..ca28938 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,6 +6,7 @@ import type { ImageInfo, Project, ProjectCreate, + ProjectRename, ProjectInfo, } from '@/types'; @@ -30,6 +31,35 @@ export function setCurrentProjectId(projectId: string | null): void { } } +/** Browser session lifecycle API */ +interface BrowserSessionPayload { + token: string; +} + +export async function sendBrowserSessionHeartbeat(token: string): Promise { + const payload: BrowserSessionPayload = { token }; + await api.post('/session/heartbeat', payload); +} + +export function sendBrowserSessionClose(token: string): void { + const payload: BrowserSessionPayload = { token }; + const body = JSON.stringify(payload); + const closeUrl = '/api/session/close'; + + if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + const blob = new Blob([body], { type: 'application/json' }); + navigator.sendBeacon(closeUrl, blob); + return; + } + + void fetch(closeUrl, { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json' }, + keepalive: true, + }); +} + /** Project Management API */ export async function listProjects(): Promise { const response = await api.get('/projects'); @@ -63,6 +93,11 @@ export async function deleteProject(projectId: string): Promise { await api.delete(`/projects/${encodeURIComponent(projectId)}`); } +export async function renameProject(projectId: string, rename: ProjectRename): Promise { + const response = await api.patch(`/projects/${encodeURIComponent(projectId)}`, rename); + return response.data; +} + /** Project Info API */ export async function getProjectInfo(): Promise { const response = await api.get('/project'); diff --git a/frontend/src/test/mocks/handlers.ts b/frontend/src/test/mocks/handlers.ts index 818f3fe..9318354 100644 --- a/frontend/src/test/mocks/handlers.ts +++ b/frontend/src/test/mocks/handlers.ts @@ -1,5 +1,12 @@ import { http, HttpResponse } from 'msw'; -import type { Annotation, AnnotationCreate, ImageInfo, Project, ProjectCreate } from '@/types'; +import type { + Annotation, + AnnotationCreate, + ImageInfo, + Project, + ProjectCreate, + ProjectRename, +} from '@/types'; // Mock data export const mockProjects: Project[] = [ @@ -50,6 +57,15 @@ export const mockProjectInfo = { let currentProjectId: string | null = null; export const handlers = [ + // Browser session lifecycle endpoints + http.post('/api/session/heartbeat', () => { + return HttpResponse.json({ ok: true }); + }), + + http.post('/api/session/close', () => { + return HttpResponse.json({ ok: true }); + }), + // Project endpoints http.get('/api/projects', () => { return HttpResponse.json(mockProjects); @@ -86,6 +102,19 @@ export const handlers = [ return HttpResponse.json(project); }), + http.patch('/api/projects/:projectId', async ({ params, request }) => { + const { projectId } = params; + const body = (await request.json()) as ProjectRename; + const project = mockProjects.find((p) => p.id === projectId); + if (!project) { + return HttpResponse.json({ detail: 'Project not found' }, { status: 404 }); + } + return HttpResponse.json({ + ...project, + name: body.name, + }); + }), + http.post('/api/projects/close', () => { currentProjectId = null; return new HttpResponse(null, { status: 204 }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 622eb40..5114d99 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -60,6 +60,11 @@ export interface ProjectCreate { name: string; } +/** Request to rename a project */ +export interface ProjectRename { + name: string; +} + /** Rectangle for drawing (pixel coordinates) */ export interface DrawingRect { x: number; @@ -70,3 +75,6 @@ export interface DrawingRect { /** Tool modes for the canvas */ export type ToolMode = 'select' | 'draw' | 'pan'; + +/** Color rendering modes for annotation bounding boxes */ +export type BoundingBoxColorMode = 'label' | 'auto' | 'custom'; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 6da1295..c9385c9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -9,6 +9,17 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + build: { + rollupOptions: { + output: { + manualChunks: { + react: ['react', 'react-dom'], + canvas: ['konva', 'react-konva'], + network: ['axios'], + }, + }, + }, + }, server: { port: 5173, proxy: { diff --git a/src/api/routes.py b/src/api/routes.py index e4f2f74..2189a3a 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -15,6 +15,7 @@ UploadFile, ) from fastapi.responses import FileResponse +from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address @@ -26,8 +27,14 @@ ProjectInfo, ) from src.services.annotation_service import AnnotationService +from src.services.browser_session_service import BrowserSessionService from src.services.export_service import ExportService -from src.services.project_service import Project, ProjectCreate, ProjectService +from src.services.project_service import ( + Project, + ProjectCreate, + ProjectRename, + ProjectService, +) from src.utils import validate_path_in_directory router = APIRouter() @@ -39,6 +46,12 @@ limiter = Limiter(key_func=get_remote_address) +class BrowserSessionPayload(BaseModel): + """Payload for browser session lifecycle signals.""" + + token: str + + def get_projects_dir() -> Path: """Get the projects directory from environment or default.""" env_path = os.environ.get("BBANNOTATE_PROJECTS_DIR") @@ -113,6 +126,14 @@ def get_export_service( return ExportService(annotation_service) +def _get_browser_session_service(request: Request) -> BrowserSessionService | None: + """Get browser session service from app state if enabled.""" + service = getattr(request.app.state, "browser_session_service", None) + if isinstance(service, BrowserSessionService): + return service + return None + + # Health check endpoint @router.get("/health") def health_check() -> dict[str, str]: @@ -120,6 +141,40 @@ def health_check() -> dict[str, str]: return {"status": "healthy", "api": "ready"} +@router.post("/session/heartbeat") +def browser_session_heartbeat( + payload: BrowserSessionPayload, + request: Request, +) -> dict[str, bool]: + """Receive browser heartbeat for detached-session lifecycle tracking.""" + service = _get_browser_session_service(request) + if service is None: + raise HTTPException( + status_code=404, + detail="Browser session lifecycle is disabled", + ) + if not service.record_heartbeat(payload.token): + raise HTTPException(status_code=403, detail="Invalid browser session token") + return {"ok": True} + + +@router.post("/session/close") +def browser_session_close( + payload: BrowserSessionPayload, + request: Request, +) -> dict[str, bool]: + """Receive browser-close signal and schedule process shutdown.""" + service = _get_browser_session_service(request) + if service is None: + raise HTTPException( + status_code=404, + detail="Browser session lifecycle is disabled", + ) + if not service.record_close(payload.token): + raise HTTPException(status_code=403, detail="Invalid browser session token") + return {"ok": True} + + # Project management endpoints @router.get("/projects", response_model=list[Project]) def list_projects( @@ -165,6 +220,23 @@ def open_project( return project +@router.patch("/projects/{project_id}", response_model=Project) +def rename_project( + project_id: str, + rename: ProjectRename, + service: Annotated[ProjectService, Depends(get_project_service)], +) -> Project: + """Rename an existing project.""" + try: + project = service.rename_project(project_id, rename.name) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + @router.post("/projects/close") def close_project() -> dict[str, bool]: """Close the current project. diff --git a/src/cli.py b/src/cli.py index 5960931..511ac68 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,10 +1,16 @@ """Command-line interface for bbannotate.""" +import os +import secrets import subprocess import sys import threading import time +import urllib.error +import urllib.parse +import urllib.request import webbrowser +from contextlib import suppress from pathlib import Path from typing import Annotated @@ -48,6 +54,83 @@ def main_callback( pass +def _configure_environment( + data_dir: Path | None, + projects_dir: Path | None, + session_token: str | None = None, +) -> dict[str, str]: + """Build environment variables for server process startup.""" + env = os.environ.copy() + if data_dir: + env["BBANNOTATE_DATA_DIR"] = str(data_dir.resolve()) + if projects_dir: + env["BBANNOTATE_PROJECTS_DIR"] = str(projects_dir.resolve()) + if session_token: + env["BBANNOTATE_SESSION_TOKEN"] = session_token + return env + + +def _wait_for_server_ready(url: str, timeout_seconds: float = 12.0) -> bool: + """Poll health endpoint until server responds or timeout is reached.""" + health_url = f"{url}/api/health" + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(health_url, timeout=0.5): + return True + except (urllib.error.URLError, TimeoutError, OSError): + time.sleep(0.1) + return False + + +def _open_browser_after_ready(server_url: str, browser_url: str) -> None: + """Open browser once server is healthy, with fallback after timeout.""" + if _wait_for_server_ready(server_url): + webbrowser.open(browser_url) + return + webbrowser.open(browser_url) + + +def _start_detached_server( + host: str, + port: int, + env: dict[str, str], +) -> subprocess.Popen: + """Start uvicorn in a detached subprocess that survives terminal close.""" + command = [ + sys.executable, + "-m", + "uvicorn", + "src.main:app", + "--host", + host, + "--port", + str(port), + ] + + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr( + subprocess, "DETACHED_PROCESS", 0 + ) + return subprocess.Popen( + command, + env=env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=creationflags, + ) + + return subprocess.Popen( + command, + env=env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + @app.command() def start( host: Annotated[ @@ -87,14 +170,6 @@ def start( Launches the FastAPI backend server and optionally opens a browser. The frontend is served from the built assets if available. """ - import os - - # Set environment variables for configuration - if data_dir: - os.environ["BBANNOTATE_DATA_DIR"] = str(data_dir.resolve()) - if projects_dir: - os.environ["BBANNOTATE_PROJECTS_DIR"] = str(projects_dir.resolve()) - # Check if frontend is built frontend_dist = find_frontend_dist() if frontend_dist is None: @@ -111,6 +186,11 @@ def start( ) url = f"http://{host}:{port}" + detached_mode = not reload + browser_session_token = ( + secrets.token_urlsafe(24) if detached_mode and not no_browser else None + ) + env = _configure_environment(data_dir, projects_dir, browser_session_token) console.print( Panel( @@ -118,53 +198,54 @@ def start( f" URL: [link={url}]{url}[/link]\n" f" Host: {host}\n" f" Port: {port}\n" - f" Reload: {'enabled' if reload else 'disabled'}", + f" Reload: {'enabled' if reload else 'disabled'}\n" + f" Detached: {'enabled' if detached_mode else 'disabled'}", title="🚀 Bbannotate", border_style="blue", ) ) - # Open browser after server is ready (in background thread with loading animation) + if detached_mode: + process = _start_detached_server(host, port, env) + if not _wait_for_server_ready(url, timeout_seconds=15.0): + with suppress(ProcessLookupError): + process.terminate() + console.print( + "[red]Failed to start server in detached mode.[/red] " + "Please check whether the port is already in use." + ) + raise typer.Exit(1) + + console.print( + f"[green]✓[/green] Server started in background (PID: {process.pid})" + ) + + if not no_browser: + browser_url = url + if browser_session_token: + encoded = urllib.parse.quote(browser_session_token, safe="") + browser_url = f"{url}?bb_session={encoded}" + webbrowser.open(browser_url) + console.print( + "[cyan]Browser session linked to server lifecycle.[/cyan] " + "Closing the browser window will stop the background server." + ) + else: + console.print( + "[yellow]Browser auto-open disabled.[/yellow] " + "Server will continue running until manually stopped." + ) + return + + # Foreground mode is reserved for dev reload workflow. + os.environ.update(env) if not no_browser: - server_ready = threading.Event() - - def open_browser_when_ready() -> None: - """Wait for server to be ready, then open browser.""" - import urllib.error - import urllib.request - - health_url = f"{url}/api/health" - max_attempts = 50 # 5 seconds max wait - for _ in range(max_attempts): - try: - with urllib.request.urlopen(health_url, timeout=0.5): - server_ready.set() - webbrowser.open(url) - return - except (urllib.error.URLError, TimeoutError, OSError): - time.sleep(0.1) - # Fallback: open anyway after timeout - server_ready.set() - webbrowser.open(url) - - def show_loading_spinner() -> None: - """Show a loading spinner until server is ready.""" - from rich.live import Live - from rich.spinner import Spinner - from rich.text import Text - - spinner = Spinner("dots", text=Text(" Waiting for server...", style="cyan")) - with Live(spinner, console=console, refresh_per_second=10, transient=True): - while not server_ready.wait(timeout=0.1): - pass - console.print("[green]✓[/green] Server ready!") - - browser_thread = threading.Thread(target=open_browser_when_ready, daemon=True) - spinner_thread = threading.Thread(target=show_loading_spinner, daemon=True) - browser_thread.start() - spinner_thread.start() - - # Start uvicorn (blocking) + threading.Thread( + target=_open_browser_after_ready, + args=(url, url), + daemon=True, + ).start() + import uvicorn uvicorn.run( diff --git a/src/main.py b/src/main.py index c9103e6..3f72e5b 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,9 @@ """FastAPI application entry point for the annotation tool.""" import os +import signal +import threading +from contextlib import asynccontextmanager from typing import cast from fastapi import FastAPI @@ -12,13 +15,76 @@ from src import __version__ from src.api.routes import get_data_dir, limiter, router +from src.services.browser_session_service import BrowserSessionService from src.utils import find_frontend_dist + +def _get_timeout(env_var: str, default: float) -> float: + """Read a timeout value from environment with safe fallback.""" + raw = os.getenv(env_var) + if raw is None: + return default + try: + value = float(raw) + except ValueError: + return default + if value <= 0: + return default + return value + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage detached browser-session monitor lifecycle.""" + token = os.getenv("BBANNOTATE_SESSION_TOKEN", "").strip() + stop_event: threading.Event | None = None + + if token: + service = BrowserSessionService( + token=token, + inactivity_timeout_seconds=_get_timeout( + "BBANNOTATE_SESSION_IDLE_TIMEOUT_SECONDS", + 8.0, + ), + close_grace_seconds=_get_timeout( + "BBANNOTATE_SESSION_CLOSE_GRACE_SECONDS", + 1.5, + ), + startup_timeout_seconds=_get_timeout( + "BBANNOTATE_SESSION_STARTUP_TIMEOUT_SECONDS", + 120.0, + ), + ) + + stop_event = threading.Event() + + def shutdown_process() -> None: + os.kill(os.getpid(), signal.SIGTERM) + + monitor_thread = threading.Thread( + target=service.run_monitor, + args=(stop_event, shutdown_process), + daemon=True, + name="bbannotate-session-monitor", + ) + monitor_thread.start() + + app.state.browser_session_service = service + app.state.browser_session_stop_event = stop_event + + try: + yield + finally: + if stop_event is not None: + stop_event.set() + + # Create FastAPI app app = FastAPI( title="Bounding Box Annotation Tool", description="A lightweight annotation tool for grocery flyer product detection", version=__version__, + lifespan=lifespan, ) # Add rate limiter state and exception handler diff --git a/src/services/__init__.py b/src/services/__init__.py index a3cb71c..0ce6393 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -1,6 +1,7 @@ """Services for the annotation tool.""" from src.services.annotation_service import AnnotationService +from src.services.browser_session_service import BrowserSessionService from src.services.export_service import ExportService -__all__ = ["AnnotationService", "ExportService"] +__all__ = ["AnnotationService", "BrowserSessionService", "ExportService"] diff --git a/src/services/browser_session_service.py b/src/services/browser_session_service.py new file mode 100644 index 0000000..1d352c8 --- /dev/null +++ b/src/services/browser_session_service.py @@ -0,0 +1,104 @@ +"""Browser session lifecycle service. + +Tracks browser heartbeat activity for detached bbannotate sessions and +decides when the server should terminate. +""" + +from __future__ import annotations + +import secrets +import threading +import time +from collections.abc import Callable + + +class BrowserSessionService: + """Manage heartbeat state for one browser-backed session.""" + + def __init__( + self, + token: str, + inactivity_timeout_seconds: float = 8.0, + close_grace_seconds: float = 1.5, + startup_timeout_seconds: float = 120.0, + ) -> None: + """Initialize browser session tracking. + + Args: + token: Random session token shared between CLI-launched browser and API. + inactivity_timeout_seconds: Max heartbeat silence before shutdown. + close_grace_seconds: Grace window after explicit close signal. + startup_timeout_seconds: Max time waiting for first heartbeat. + """ + self.token = token + self.inactivity_timeout_seconds = inactivity_timeout_seconds + self.close_grace_seconds = close_grace_seconds + self.startup_timeout_seconds = startup_timeout_seconds + + self._lock = threading.Lock() + self._started_at = time.monotonic() + self._last_heartbeat_at: float | None = None + self._close_requested_at: float | None = None + self._shutdown_triggered = False + + def record_heartbeat(self, token: str) -> bool: + """Record a heartbeat for an authenticated session.""" + if not self._is_valid_token(token): + return False + + with self._lock: + self._last_heartbeat_at = time.monotonic() + self._close_requested_at = None + return True + + def record_close(self, token: str) -> bool: + """Record an explicit browser close signal.""" + if not self._is_valid_token(token): + return False + + with self._lock: + self._close_requested_at = time.monotonic() + return True + + def run_monitor( + self, + stop_event: threading.Event, + shutdown_callback: Callable[[], None], + ) -> None: + """Monitor lifecycle state and invoke shutdown callback once needed.""" + while not stop_event.wait(timeout=0.5): + if self._should_shutdown(): + shutdown_callback() + return + + def _should_shutdown(self) -> bool: + """Return True if the process should terminate.""" + now = time.monotonic() + + with self._lock: + if self._shutdown_triggered: + return False + + # Browser explicitly closed: terminate quickly unless heartbeat resumes. + if self._close_requested_at is not None: + if now - self._close_requested_at >= self.close_grace_seconds: + self._shutdown_triggered = True + return True + return False + + # If never connected, avoid orphan detached servers forever. + if self._last_heartbeat_at is None: + if now - self._started_at >= self.startup_timeout_seconds: + self._shutdown_triggered = True + return True + return False + + if now - self._last_heartbeat_at >= self.inactivity_timeout_seconds: + self._shutdown_triggered = True + return True + + return False + + def _is_valid_token(self, token: str) -> bool: + """Validate session token in constant time.""" + return bool(token) and secrets.compare_digest(token, self.token) diff --git a/src/services/project_service.py b/src/services/project_service.py index 55c9327..403aa22 100644 --- a/src/services/project_service.py +++ b/src/services/project_service.py @@ -27,6 +27,12 @@ class ProjectCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100, description="Project name") +class ProjectRename(BaseModel): + """Request model for renaming a project.""" + + name: str = Field(..., min_length=1, max_length=100, description="New project name") + + class ProjectService: """Handles project storage and management.""" @@ -177,6 +183,43 @@ def open_project(self, project_id: str) -> Project | None: self._save_project_meta(project) return project + def rename_project(self, project_id: str, new_name: str) -> Project | None: + """Rename an existing project. + + Args: + project_id: Project identifier. + new_name: New human-readable project name. + + Returns: + Updated project if found, None otherwise. + + Raises: + ValueError: If name is empty after trimming or if path is invalid. + """ + cleaned_name = new_name.strip() + if not cleaned_name: + raise ValueError("Project name cannot be empty") + + project_dir = self._get_project_dir(project_id) + if not project_dir.exists(): + return None + + # Security: Validate path stays within base_dir to prevent path traversal + if not validate_path_in_directory(project_dir, self.base_dir): + raise ValueError(f"Invalid project path: {project_id}") + + project = self._load_project_meta(project_id) + if not project: + return None + + project.name = cleaned_name + image_count, annotation_count = self._count_project_stats(project_id) + project.image_count = image_count + project.annotation_count = annotation_count + + self._save_project_meta(project) + return project + def delete_project(self, project_id: str) -> bool: """Delete a project and all its data. diff --git a/tests/test_api.py b/tests/test_api.py index 14805ee..705caf6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -68,6 +68,53 @@ def test_root_health_check(self, client: TestClient) -> None: assert data["status"] == "healthy" +class TestBrowserSessionEndpoints: + """Tests for detached browser session lifecycle endpoints.""" + + def test_session_heartbeat_disabled_returns_404(self, client: TestClient) -> None: + """Heartbeat should be unavailable when session lifecycle is disabled.""" + response = client.post("/api/session/heartbeat", json={"token": "test-token"}) + assert response.status_code == 404 + + def test_session_heartbeat_accepts_valid_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Heartbeat succeeds when token matches configured detached session.""" + token = "test-browser-session-token" + monkeypatch.setenv("BBANNOTATE_SESSION_TOKEN", token) + with TestClient(app) as token_client: + response = token_client.post( + "/api/session/heartbeat", + json={"token": token}, + ) + assert response.status_code == 200 + assert response.json()["ok"] is True + + def test_session_heartbeat_rejects_invalid_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Heartbeat should reject requests with invalid token.""" + monkeypatch.setenv("BBANNOTATE_SESSION_TOKEN", "expected-token") + with TestClient(app) as token_client: + response = token_client.post( + "/api/session/heartbeat", + json={"token": "wrong-token"}, + ) + assert response.status_code == 403 + + def test_session_close_rejects_invalid_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Close endpoint should reject invalid session token.""" + monkeypatch.setenv("BBANNOTATE_SESSION_TOKEN", "expected-token") + with TestClient(app) as token_client: + response = token_client.post( + "/api/session/close", + json={"token": "wrong-token"}, + ) + assert response.status_code == 403 + + class TestProjectEndpoints: """Tests for project-level endpoints.""" @@ -104,6 +151,56 @@ def test_get_project_info_with_data( assert "product" in data["labels"] +class TestProjectManagementEndpoints: + """Tests for project management endpoints.""" + + def test_rename_project(self, client: TestClient, temp_data_dir: Path) -> None: + """Test renaming an existing project.""" + create_response = client.post("/api/projects", json={"name": "Old Name"}) + assert create_response.status_code == 200 + project_id = create_response.json()["id"] + + rename_response = client.patch( + f"/api/projects/{project_id}", + json={"name": "New Name"}, + ) + assert rename_response.status_code == 200 + renamed = rename_response.json() + assert renamed["id"] == project_id + assert renamed["name"] == "New Name" + + list_response = client.get("/api/projects") + assert list_response.status_code == 200 + projects = list_response.json() + assert any(p["id"] == project_id and p["name"] == "New Name" for p in projects) + + def test_rename_project_not_found( + self, client: TestClient, temp_data_dir: Path + ) -> None: + """Test renaming a non-existent project returns 404.""" + response = client.patch( + "/api/projects/nonexistent", + json={"name": "New Name"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Project not found" + + def test_rename_project_whitespace_name_returns_400( + self, client: TestClient, temp_data_dir: Path + ) -> None: + """Test renaming with whitespace-only name returns 400.""" + create_response = client.post("/api/projects", json={"name": "Original"}) + assert create_response.status_code == 200 + project_id = create_response.json()["id"] + + response = client.patch( + f"/api/projects/{project_id}", + json={"name": " "}, + ) + assert response.status_code == 400 + assert "cannot be empty" in response.json()["detail"].lower() + + class TestImageEndpoints: """Tests for image-related endpoints.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 5e69f85..dd67904 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -84,42 +84,54 @@ def test_start_help(self, runner: CliRunner) -> None: assert "--data-dir" in clean_output assert "--projects-dir" in clean_output - @patch("uvicorn.run") + @patch("src.cli._wait_for_server_ready", return_value=True) + @patch("src.cli._start_detached_server") @patch("webbrowser.open") def test_start_default_options( self, mock_webbrowser: MagicMock, - mock_uvicorn: MagicMock, + mock_start_detached: MagicMock, + mock_wait_ready: MagicMock, runner: CliRunner, ) -> None: - """Test start command with default options.""" + """Test start command launches detached server by default.""" + mock_process = MagicMock(pid=4242) + mock_start_detached.return_value = mock_process runner.invoke(app, ["start", "--no-browser"]) - # Note: The command will try to start uvicorn - mock_uvicorn.assert_called_once_with( - "src.main:app", - host="127.0.0.1", - port=8000, - reload=False, + mock_start_detached.assert_called_once() + args, kwargs = mock_start_detached.call_args + assert args[0] == "127.0.0.1" + assert args[1] == 8000 + env = args[2] + assert "BBANNOTATE_SESSION_TOKEN" not in env + mock_wait_ready.assert_called_once_with( + "http://127.0.0.1:8000", + timeout_seconds=15.0, ) mock_webbrowser.assert_not_called() - @patch("uvicorn.run") + @patch("src.cli._wait_for_server_ready", return_value=True) + @patch("src.cli._start_detached_server") @patch("webbrowser.open") def test_start_custom_host_port( self, mock_webbrowser: MagicMock, - mock_uvicorn: MagicMock, + mock_start_detached: MagicMock, + mock_wait_ready: MagicMock, runner: CliRunner, ) -> None: """Test start command with custom host and port.""" + mock_start_detached.return_value = MagicMock(pid=9999) runner.invoke( app, ["start", "--host", "0.0.0.0", "--port", "9000", "--no-browser"] ) - mock_uvicorn.assert_called_once_with( - "src.main:app", - host="0.0.0.0", - port=9000, - reload=False, + mock_start_detached.assert_called_once() + args, kwargs = mock_start_detached.call_args + assert args[0] == "0.0.0.0" + assert args[1] == 9000 + mock_wait_ready.assert_called_once_with( + "http://0.0.0.0:9000", + timeout_seconds=15.0, ) @patch("uvicorn.run") @@ -139,78 +151,95 @@ def test_start_with_reload( reload=True, ) - @patch("uvicorn.run") - @patch("urllib.request.urlopen") + @patch("src.cli._wait_for_server_ready", return_value=True) + @patch("src.cli._start_detached_server") @patch("webbrowser.open") def test_start_opens_browser_by_default( self, mock_webbrowser: MagicMock, - mock_urlopen: MagicMock, - mock_uvicorn: MagicMock, + mock_start_detached: MagicMock, + mock_wait_ready: MagicMock, runner: CliRunner, ) -> None: """Test start command opens browser by default.""" - import time - - # Mock health check to succeed immediately - mock_urlopen.return_value.__enter__ = MagicMock() - mock_urlopen.return_value.__exit__ = MagicMock() + mock_start_detached.return_value = MagicMock(pid=4321) runner.invoke(app, ["start"]) - # Give the background thread time to run - time.sleep(0.2) - mock_webbrowser.assert_called_once_with("http://127.0.0.1:8000") + mock_wait_ready.assert_called_once_with( + "http://127.0.0.1:8000", + timeout_seconds=15.0, + ) + mock_webbrowser.assert_called_once() + opened_url = mock_webbrowser.call_args.args[0] + assert opened_url.startswith("http://127.0.0.1:8000?bb_session=") - @patch("uvicorn.run") + @patch("src.cli._wait_for_server_ready", return_value=True) + @patch("src.cli._start_detached_server") @patch("webbrowser.open") def test_start_no_browser_flag( self, mock_webbrowser: MagicMock, - mock_uvicorn: MagicMock, + mock_start_detached: MagicMock, + mock_wait_ready: MagicMock, runner: CliRunner, ) -> None: """Test start command with --no-browser flag.""" + mock_start_detached.return_value = MagicMock(pid=1010) runner.invoke(app, ["start", "--no-browser"]) + mock_wait_ready.assert_called_once_with( + "http://127.0.0.1:8000", + timeout_seconds=15.0, + ) mock_webbrowser.assert_not_called() - @patch("uvicorn.run") + @patch("src.cli._wait_for_server_ready", return_value=True) + @patch("src.cli._start_detached_server") @patch("webbrowser.open") def test_start_sets_data_dir_env( self, mock_webbrowser: MagicMock, - mock_uvicorn: MagicMock, + mock_start_detached: MagicMock, + mock_wait_ready: MagicMock, runner: CliRunner, temp_dir: Path, ) -> None: - """Test start command sets BBANNOTATE_DATA_DIR env variable.""" + """Test start command passes BBANNOTATE_DATA_DIR to detached process.""" data_dir = temp_dir / "custom_data" data_dir.mkdir() + mock_start_detached.return_value = MagicMock(pid=777) with patch.dict(os.environ, {}, clear=False): runner.invoke(app, ["start", "--data-dir", str(data_dir), "--no-browser"]) - # The env variable should have been set - assert "BBANNOTATE_DATA_DIR" in os.environ + mock_start_detached.assert_called_once() + args, kwargs = mock_start_detached.call_args + env = args[2] + assert env["BBANNOTATE_DATA_DIR"] == str(data_dir.resolve()) - @patch("uvicorn.run") + @patch("src.cli._wait_for_server_ready", return_value=True) + @patch("src.cli._start_detached_server") @patch("webbrowser.open") def test_start_sets_projects_dir_env( self, mock_webbrowser: MagicMock, - mock_uvicorn: MagicMock, + mock_start_detached: MagicMock, + mock_wait_ready: MagicMock, runner: CliRunner, temp_dir: Path, ) -> None: - """Test start command sets BBANNOTATE_PROJECTS_DIR env variable.""" + """Test start command passes BBANNOTATE_PROJECTS_DIR to detached process.""" projects_dir = temp_dir / "custom_projects" projects_dir.mkdir() + mock_start_detached.return_value = MagicMock(pid=555) with patch.dict(os.environ, {}, clear=False): runner.invoke( app, ["start", "--projects-dir", str(projects_dir), "--no-browser"] ) - # The env variable should have been set - assert "BBANNOTATE_PROJECTS_DIR" in os.environ + mock_start_detached.assert_called_once() + args, kwargs = mock_start_detached.call_args + env = args[2] + assert env["BBANNOTATE_PROJECTS_DIR"] == str(projects_dir.resolve()) class TestInfoCommand: diff --git a/tests/test_project_service.py b/tests/test_project_service.py index 4e64f8d..97066e0 100644 --- a/tests/test_project_service.py +++ b/tests/test_project_service.py @@ -108,6 +108,36 @@ def test_delete_project_not_found(self, service: ProjectService) -> None: success = service.delete_project("nonexistent") assert success is False + def test_rename_project(self, service: ProjectService) -> None: + """Test renaming an existing project.""" + created = service.create_project(ProjectCreate(name="Old Name")) + + renamed = service.rename_project(created.id, "New Name") + assert renamed is not None + assert renamed.id == created.id + assert renamed.name == "New Name" + + fetched = service.get_project(created.id) + assert fetched is not None + assert fetched.name == "New Name" + + def test_rename_project_not_found(self, service: ProjectService) -> None: + """Test renaming a non-existent project.""" + result = service.rename_project("nonexistent", "Renamed") + assert result is None + + def test_rename_project_empty_name_fails(self, service: ProjectService) -> None: + """Test renaming with empty/whitespace-only name fails.""" + created = service.create_project(ProjectCreate(name="Original")) + + with pytest.raises(ValueError, match="cannot be empty"): + service.rename_project(created.id, " ") + + def test_rename_project_path_traversal_fails(self, service: ProjectService) -> None: + """Test renaming with path traversal project ID is rejected.""" + with pytest.raises(ValueError, match="Invalid project path"): + service.rename_project("..", "Renamed") + def test_get_project_data_dir(self, service: ProjectService) -> None: """Test getting project data directory.""" created = service.create_project(ProjectCreate(name="Test")) From 13a211918368a5d1397b3b6ed17ce8d8ba336057 Mon Sep 17 00:00:00 2001 From: Sebastian Yde Madsen <75695915+seba2390@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:28:01 +0100 Subject: [PATCH 4/4] feat(cli): add runtime status command and refresh README docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add Bbannotate Status ┏━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Component ┃ State ┃ Details ┃ ┡━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Backend API │ stopped │ port 8000: closed, health: unreachable, │ │ │ │ processes: 0 │ │ Frontend Dev │ stopped │ port 5173: closed, processes: 0 │ └──────────────┴─────────┴───────────────────────────────────────────────┘ command to report backend/frontend runtime state - include port reachability, API health checks, and detected process list (PID + command) - add CLI tests for status help, stopped-state, and running-state scenarios - update README with detached startup behavior, browser-linked shutdown, and runtime modes - document new Bbannotate Status ┏━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Component ┃ State ┃ Details ┃ ┡━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Backend API │ stopped │ port 8000: closed, health: unreachable, │ │ │ │ processes: 0 │ │ Frontend Dev │ stopped │ port 5173: closed, processes: 0 │ └──────────────┴─────────┴───────────────────────────────────────────────┘ command and current keyboard shortcuts - clean up outdated README CLI/options and testing notes --- README.md | 20 ++++-- frontend/package-lock.json | 36 +++++----- src/cli.py | 139 +++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 66 ++++++++++++++++++ 4 files changed, 238 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 5af5388..c0f61f5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A lightweight bounding box annotation tool for image datasets. Built with React/ - 🖼️ **Multi-format support** — PNG, JPEG, WebP, BMP - 📁 **Project management** — Organize annotations by project - 🏷️ **Custom labels** — Define your own class labels +- 🎨 **Adaptive box colors** — Auto contrast, label-based, or custom color modes - ⌨️ **Keyboard shortcuts** — Fast annotation workflow - 📤 **Multiple export formats** — YOLO, COCO, Pascal VOC, CreateML, CSV - 🔄 **Train/Val/Test split** — Automatic dataset splitting for YOLO export @@ -36,9 +37,17 @@ pip install bbannotate # Start the annotation server bbannotate start -# Opens http://127.0.0.1:8000 in your browser +# Opens http://127.0.0.1:8000 in your browser. +# By default the server runs detached, so you can close the terminal. +# Closing that browser session stops the detached server automatically. ``` +### Runtime Modes + +- **Default (`bbannotate start`)**: Starts server in detached mode and links lifecycle to the opened browser session. +- **Development (`bbannotate start --reload`)**: Runs in foreground with auto-reload. +- **No Browser (`bbannotate start --no-browser`)**: Starts server without opening browser automatically. + ### CLI Options ```bash @@ -51,15 +60,15 @@ Options: -r, --reload Enable auto-reload for development -d, --data-dir PATH Directory for storing data [default: ./data] --projects-dir PATH Directory for storing projects [default: ./projects] - -v, --version Show version and exit --help Show help and exit ``` ### Other Commands ```bash -bbannotate info # Show installation info -bbannotate build-frontend # Build frontend assets (development) +bbannotate info # Show installation info +bbannotate status # Show runtime status (backend/frontend processes + ports) +bbannotate build-frontend # Build frontend assets (development) ``` ## Keyboard Shortcuts @@ -74,6 +83,7 @@ bbannotate build-frontend # Build frontend assets (development) | `Del` / `Backspace` | Delete annotation | | `⌘Z` / `Ctrl+Z` | Undo last annotation | | `Esc` | Deselect / Cancel | +| `Enter` | Mark image done | ## Export Formats @@ -134,7 +144,7 @@ frontend/ # React/TypeScript frontend src/ components/ # UI components hooks/ # React hooks -tests/ # Test suite (145 tests) +tests/ # Python test suite ``` ## License diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84332b1..0ab4139 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2478,13 +2478,13 @@ } }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -4991,9 +4991,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.12.9", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.9.tgz", - "integrity": "sha512-NYbi51C6M3dujGmcmuGemu68jy12KqQPoVWGeroKToLGsBgrwG5ErM8WctoIIg49/EV49SEvYM9WSqO4G7kNeQ==", + "version": "2.12.10", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", + "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5049,9 +5049,9 @@ } }, "node_modules/msw/node_modules/type-fest": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.3.tgz", - "integrity": "sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -6653,22 +6653,22 @@ } }, "node_modules/tldts": { - "version": "7.0.22", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", - "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.22" + "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.22", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.22.tgz", - "integrity": "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "dev": true, "license": "MIT" }, diff --git a/src/cli.py b/src/cli.py index 511ac68..b57a146 100644 --- a/src/cli.py +++ b/src/cli.py @@ -2,6 +2,7 @@ import os import secrets +import socket import subprocess import sys import threading @@ -91,6 +92,77 @@ def _open_browser_after_ready(server_url: str, browser_url: str) -> None: webbrowser.open(browser_url) +def _is_tcp_port_open(host: str, port: int, timeout_seconds: float = 0.4) -> bool: + """Check if a TCP port is accepting connections.""" + with ( + suppress(OSError), + socket.create_connection( + (host, port), + timeout=timeout_seconds, + ), + ): + return True + return False + + +def _check_api_health(url: str, timeout_seconds: float = 0.8) -> bool: + """Check if API health endpoint is responding.""" + health_url = f"{url}/api/health" + try: + with urllib.request.urlopen(health_url, timeout=timeout_seconds) as response: + return response.status == 200 + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +def _list_running_processes() -> list[tuple[int, str]]: + """Return process list as (pid, command) tuples.""" + if os.name == "nt": + return [] + + result = subprocess.run( + ["ps", "-axo", "pid=,command="], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [] + + processes: list[tuple[int, str]] = [] + for raw_line in result.stdout.splitlines(): + line = raw_line.strip() + if not line: + continue + pid_raw, _, command = line.partition(" ") + if not pid_raw.isdigit(): + continue + processes.append((int(pid_raw), command.strip())) + return processes + + +def _find_backend_processes( + processes: list[tuple[int, str]], +) -> list[tuple[int, str]]: + """Find likely bbannotate backend processes.""" + matches: list[tuple[int, str]] = [] + for pid, command in processes: + if "uvicorn" in command and "src.main:app" in command: + matches.append((pid, command)) + return matches + + +def _find_frontend_processes( + processes: list[tuple[int, str]], +) -> list[tuple[int, str]]: + """Find likely frontend dev server processes.""" + matches: list[tuple[int, str]] = [] + for pid, command in processes: + if "vite" in command and "vitest" not in command: + matches.append((pid, command)) + return matches + + def _start_detached_server( host: str, port: int, @@ -331,6 +403,73 @@ def info() -> None: ) +@app.command() +def status( + host: Annotated[ + str, + typer.Option("--host", "-h", help="Host to check for backend status."), + ] = "127.0.0.1", + port: Annotated[ + int, + typer.Option("--port", "-p", help="Backend port to check."), + ] = 8000, + frontend_port: Annotated[ + int, + typer.Option("--frontend-port", help="Frontend dev server port to check."), + ] = 5173, +) -> None: + """Show current bbannotate runtime status.""" + from rich.table import Table + + backend_url = f"http://{host}:{port}" + backend_port_open = _is_tcp_port_open(host, port) + frontend_port_open = _is_tcp_port_open(host, frontend_port) + backend_healthy = _check_api_health(backend_url) + + all_processes = _list_running_processes() + backend_processes = _find_backend_processes(all_processes) + frontend_processes = _find_frontend_processes(all_processes) + + table = Table(title="Bbannotate Status") + table.add_column("Component", style="bold") + table.add_column("State") + table.add_column("Details") + + backend_state = ( + "running" + if backend_port_open or backend_healthy or backend_processes + else "stopped" + ) + backend_details = ( + f"port {port}: {'open' if backend_port_open else 'closed'}, " + f"health: {'ok' if backend_healthy else 'unreachable'}, " + f"processes: {len(backend_processes)}" + ) + table.add_row("Backend API", backend_state, backend_details) + + frontend_state = ( + "running" if frontend_port_open or frontend_processes else "stopped" + ) + frontend_details = ( + f"port {frontend_port}: {'open' if frontend_port_open else 'closed'}, " + f"processes: {len(frontend_processes)}" + ) + table.add_row("Frontend Dev", frontend_state, frontend_details) + console.print(table) + + if backend_processes or frontend_processes: + process_table = Table(title="Detected Processes") + process_table.add_column("PID", style="cyan", justify="right") + process_table.add_column("Type") + process_table.add_column("Command") + + for pid, command in backend_processes: + process_table.add_row(str(pid), "backend", command) + for pid, command in frontend_processes: + process_table.add_row(str(pid), "frontend", command) + console.print(process_table) + + def _find_frontend_src() -> Path | None: """Find the frontend source directory.""" # Check relative to package diff --git a/tests/test_cli.py b/tests/test_cli.py index dd67904..32e7618 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,6 +55,7 @@ def test_help_flag(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert "Bounding box annotation tool" in result.output assert "start" in result.output + assert "status" in result.output assert "build-frontend" in result.output assert "info" in result.output @@ -265,6 +266,71 @@ def test_info_shows_frontend_status(self, runner: CliRunner) -> None: assert "Frontend" in result.output +class TestStatusCommand: + """Tests for the status command.""" + + def test_status_help(self, runner: CliRunner) -> None: + """Test status command help.""" + result = runner.invoke(app, ["status", "--help"]) + assert result.exit_code == 0 + assert "Show current bbannotate runtime status" in result.output + + @patch("src.cli._find_frontend_processes", return_value=[]) + @patch("src.cli._find_backend_processes", return_value=[]) + @patch("src.cli._list_running_processes", return_value=[]) + @patch("src.cli._check_api_health", return_value=False) + @patch("src.cli._is_tcp_port_open", return_value=False) + def test_status_reports_stopped_services( + self, + mock_is_tcp_port_open: MagicMock, + mock_check_api_health: MagicMock, + mock_list_running_processes: MagicMock, + mock_find_backend_processes: MagicMock, + mock_find_frontend_processes: MagicMock, + runner: CliRunner, + ) -> None: + """Status should report stopped when nothing is running.""" + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + assert "Backend API" in result.output + assert "Frontend Dev" in result.output + assert "stopped" in result.output + + @patch("src.cli._find_frontend_processes") + @patch("src.cli._find_backend_processes") + @patch("src.cli._list_running_processes") + @patch("src.cli._check_api_health", return_value=True) + @patch("src.cli._is_tcp_port_open") + def test_status_reports_running_services( + self, + mock_is_tcp_port_open: MagicMock, + mock_check_api_health: MagicMock, + mock_list_running_processes: MagicMock, + mock_find_backend_processes: MagicMock, + mock_find_frontend_processes: MagicMock, + runner: CliRunner, + ) -> None: + """Status should report running and show detected processes.""" + mock_is_tcp_port_open.side_effect = [True, True] # backend port, frontend port + mock_list_running_processes.return_value = [ + (1234, "python -m uvicorn src.main:app --host 127.0.0.1 --port 8000"), + (5678, "node ./node_modules/vite/bin/vite.js"), + ] + mock_find_backend_processes.return_value = [ + (1234, "python -m uvicorn src.main:app --host 127.0.0.1 --port 8000") + ] + mock_find_frontend_processes.return_value = [ + (5678, "node ./node_modules/vite/bin/vite.js") + ] + + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + assert "running" in result.output + assert "Detected Processes" in result.output + assert "1234" in result.output + assert "5678" in result.output + + class TestBuildFrontendCommand: """Tests for the build-frontend command."""