From 1efbc289ba7c7bc26bd73cdec703577d8d8e0c57 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 16:36:09 +0200 Subject: [PATCH 01/10] fix(cloudflared): use --host 0.0.0.0 instead of --bind-addr for code-server The --bind-addr flag caused code-server to fail entirely (app not responding on any interface). The correct override for the coder/code-server image is --host 0.0.0.0, which overrides the entrypoint's --host 127.0.0.1. Changes: - Migration: Replace --bind-addr with --host 0.0.0.0, also handle existing broken templates by detecting --bind-addr and replacing it - Runtime safety net: _ensure_web_bind_address uses --host 0.0.0.0 - Test fixture: Updated compose template to match Quality gates: pytest 42 passed --- .../2026_05_29_fix_web_tool_bind_address.py | 36 +++++++----- apps/api/src/api/tool_instances.py | 6 +- .../test_tool_types_api_extended.py | 58 ++++++++++++++----- 3 files changed, 68 insertions(+), 32 deletions(-) diff --git a/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py b/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py index 431446f..9058a7a 100644 --- a/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py +++ b/apps/api/alembic/versions/2026_05_29_fix_web_tool_bind_address.py @@ -5,6 +5,7 @@ Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest Create Date: 2026-05-29 14:00:00.000000 """ + from typing import Sequence, Union from alembic import op @@ -35,25 +36,32 @@ def _fix_code_server_compose(conn) -> None: if definition_type != "compose" or not compose_template: return - # Add command to bind to 0.0.0.0 if not already present - if "command:" in compose_template: - # Already has a command override, skip - return - - # Insert command line after the image line + # Fix or add command to bind to 0.0.0.0 lines = compose_template.split("\n") new_lines = [] image_line_idx = -1 + command_fixed = False for i, line in enumerate(lines): + # Replace broken --bind-addr with correct --host + if "command:" in line and "--bind-addr" in line: + indent = line[: len(line) - len(line.lstrip())] + new_lines.append(f"{indent}command: --host 0.0.0.0") + command_fixed = True + continue new_lines.append(line) if "image:" in line and image_line_idx == -1: image_line_idx = i - # Insert command with proper indentation (same as image line) - indent = line[: len(line) - len(line.lstrip())] - new_lines.append(f"{indent}command: --bind-addr 0.0.0.0:8443") - if image_line_idx == -1: - # No image line found, can't safely modify + # If no command line exists, insert one after image + if not command_fixed and image_line_idx != -1: + image_line = lines[image_line_idx] + indent = image_line[: len(image_line) - len(image_line.lstrip())] + # Insert after the image line in new_lines + insert_idx = new_lines.index(image_line) + 1 + new_lines.insert(insert_idx, f"{indent}command: --host 0.0.0.0") + command_fixed = True + + if not command_fixed: return updated_compose = "\n".join(new_lines) @@ -67,7 +75,7 @@ def _fix_code_server_compose(conn) -> None: {"compose_template": updated_compose, "id": tool_id}, ) - print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0:8443") + print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0") def _fix_jupyter_compose(conn) -> None: @@ -100,7 +108,9 @@ def _fix_jupyter_compose(conn) -> None: image_line_idx = i indent = line[: len(line) - len(line.lstrip())] # Jupyter needs --ip=0.0.0.0 to bind to all interfaces - new_lines.append(f'{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser') + new_lines.append( + f"{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser" + ) if image_line_idx == -1: return diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index d9ecfe9..32ee7c1 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -629,7 +629,7 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: from pathlib import Path KNOWN_BIND_FIXES = { - "code-server": "--bind-addr 0.0.0.0:8443", + "code-server": "--host 0.0.0.0", "jupyter-notebook": "start-notebook.sh --ip=0.0.0.0", } @@ -669,9 +669,7 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: break compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) - logger.info( - "Injected bind address for %s: %s", tool_type_name, bind_command - ) + logger.info("Injected bind address for %s: %s", tool_type_name, bind_command) @router.post( diff --git a/apps/api/tests/integration/test_tool_types_api_extended.py b/apps/api/tests/integration/test_tool_types_api_extended.py index df581a0..be7755c 100644 --- a/apps/api/tests/integration/test_tool_types_api_extended.py +++ b/apps/api/tests/integration/test_tool_types_api_extended.py @@ -6,7 +6,9 @@ from fastapi.testclient import TestClient class TestToolTypesAPIExtended: """Integration tests for tool types API with new fields.""" - def test_create_tool_type_with_dockerfile(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_dockerfile( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with dockerfile definition.""" response = authenticated_client.post( "/tool-types", @@ -27,7 +29,9 @@ class TestToolTypesAPIExtended: assert data["definition_type"] == "dockerfile" assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask" - def test_create_tool_type_with_readiness_probe(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_readiness_probe( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with readiness probe.""" response = authenticated_client.post( "/tool-types", @@ -52,7 +56,9 @@ class TestToolTypesAPIExtended: assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080" assert data["readiness_probe"]["timeout"] == 30 - def test_create_tool_type_invalid_definition_type(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_invalid_definition_type( + self, authenticated_client: TestClient + ) -> None: """Test that invalid definition types are rejected.""" response = authenticated_client.post( "/tool-types", @@ -67,7 +73,9 @@ class TestToolTypesAPIExtended: ) assert response.status_code == 422 - def test_create_tool_type_dockerfile_without_template(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_dockerfile_without_template( + self, authenticated_client: TestClient + ) -> None: """Test that dockerfile type requires dockerfile_template.""" response = authenticated_client.post( "/tool-types", @@ -81,7 +89,9 @@ class TestToolTypesAPIExtended: ) assert response.status_code == 422 - def test_update_tool_type_with_new_fields(self, authenticated_client: TestClient) -> None: + def test_update_tool_type_with_new_fields( + self, authenticated_client: TestClient + ) -> None: """Test updating a tool type with new fields.""" # Create tool type first create_response = authenticated_client.post( @@ -112,7 +122,9 @@ class TestToolTypesAPIExtended: assert response.status_code == 200 data = response.json() assert data["display_name"] == "Updated Name" - assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health" + assert ( + data["readiness_probe"]["command"] == "curl -f http://localhost:8080/health" + ) def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None: """Test validating compose template.""" @@ -127,7 +139,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["valid"] is True - def test_validate_tool_type_invalid_compose(self, authenticated_client: TestClient) -> None: + def test_validate_tool_type_invalid_compose( + self, authenticated_client: TestClient + ) -> None: """Test validating invalid compose template.""" response = authenticated_client.post( "/tool-types/validate", @@ -141,7 +155,9 @@ class TestToolTypesAPIExtended: assert data["valid"] is False assert "errors" in data - def test_validate_tool_type_dockerfile(self, authenticated_client: TestClient) -> None: + def test_validate_tool_type_dockerfile( + self, authenticated_client: TestClient + ) -> None: """Test validating dockerfile template.""" response = authenticated_client.post( "/tool-types/validate", @@ -154,7 +170,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["valid"] is True - def test_get_tool_type_returns_new_fields(self, authenticated_client: TestClient) -> None: + def test_get_tool_type_returns_new_fields( + self, authenticated_client: TestClient + ) -> None: """Test that GET returns new fields.""" # Create tool type with all fields create_response = authenticated_client.post( @@ -166,7 +184,7 @@ class TestToolTypesAPIExtended: "interfaces": ["web", "terminal"], "default_port": 8443, "definition_type": "compose", - "compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", + "compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --host 0.0.0.0\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"", "readiness_probe": { "command": "curl -f http://localhost:8443", "timeout": 30, @@ -186,7 +204,9 @@ class TestToolTypesAPIExtended: assert data["interfaces"] == ["web", "terminal"] assert "readiness_probe" in data - def test_create_tool_type_without_port_fails(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_without_port_fails( + self, authenticated_client: TestClient + ) -> None: """Test that creating a tool type without default_port fails validation.""" response = authenticated_client.post( "/tool-types", @@ -204,7 +224,9 @@ class TestToolTypesAPIExtended: data = response.json() assert "default_port" in str(data) - def test_create_tool_type_with_port_mismatch_fails(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_port_mismatch_fails( + self, authenticated_client: TestClient + ) -> None: """Test that port mismatch between default_port and compose template fails.""" response = authenticated_client.post( "/tool-types", @@ -222,7 +244,9 @@ class TestToolTypesAPIExtended: assert response.status_code == 422 _ = response.json() - def test_create_tool_type_with_startup_command(self, authenticated_client: TestClient) -> None: + def test_create_tool_type_with_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test creating a tool type with startup_command.""" response = authenticated_client.post( "/tool-types", @@ -244,7 +268,9 @@ class TestToolTypesAPIExtended: assert data["startup_command"] == "cd /workspace && ls" assert data["interface_type"] == "terminal" - def test_update_tool_type_startup_command(self, authenticated_client: TestClient) -> None: + def test_update_tool_type_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test updating a tool type's startup_command.""" # Create tool type first create_response = authenticated_client.post( @@ -273,7 +299,9 @@ class TestToolTypesAPIExtended: data = response.json() assert data["startup_command"] == "source /etc/profile" - def test_get_tool_type_returns_startup_command(self, authenticated_client: TestClient) -> None: + def test_get_tool_type_returns_startup_command( + self, authenticated_client: TestClient + ) -> None: """Test that GET returns startup_command.""" create_response = authenticated_client.post( "/tool-types", From 2b5223097fa97ca35bac0f01406b3606aedb6e7b Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 16:42:31 +0200 Subject: [PATCH 02/10] feat: filter notifications to warnings/errors/ready only and add clear-all button Notification filtering: - lifecycle_hooks.py: only instance.error and instance.health_changed with status=running generate notifications. All other lifecycle events (created, started, stopped, restarted, deleted) are filtered out. - health_monitor.py: only error and unhealthy states generate notifications. Running/recovered state no longer creates info notifications. - _derive_title now maps instance.health_changed to "Container ready". Clear-all button: - Added dismiss_all() to NotificationService - Added DELETE /notifications endpoint for bulk dismiss - Frontend: clearAllNotifications API, clearAll in notification context, "Clear all" button in notification drawer alongside "Mark all as read" - Added CSS for .notification-clear-all with danger hover state - Updated notification-center tests Quality gates: pytest (21 passed), vitest (11 passed) --- apps/api/src/api/notifications.py | 16 +++++- apps/api/src/services/health_monitor.py | 14 +++--- apps/api/src/services/lifecycle_hooks.py | 31 +++++++++--- apps/api/src/services/notification_service.py | 26 ++++++++++ apps/api/tests/unit/test_lifecycle_hooks.py | 49 +++++++++++++++++++ .../tests/unit/test_notification_service.py | 49 +++++++++++++++++++ apps/web/src/api/notifications.ts | 9 ++++ .../components/notification-center.test.tsx | 21 ++++++++ .../src/components/notification-center.tsx | 10 ++++ apps/web/src/state/notifications.tsx | 21 ++++++++ apps/web/src/styles.css | 23 ++++++++- 11 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 apps/api/tests/unit/test_lifecycle_hooks.py diff --git a/apps/api/src/api/notifications.py b/apps/api/src/api/notifications.py index bf5259e..adaacb2 100644 --- a/apps/api/src/api/notifications.py +++ b/apps/api/src/api/notifications.py @@ -47,6 +47,10 @@ class MarkAllReadResponse(BaseModel): marked_count: int +class ClearAllResponse(BaseModel): + cleared_count: int + + async def _get_mute_categories( session: AsyncSession, user_id: uuid.UUID, @@ -137,7 +141,7 @@ async def dismiss_notification( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db_session), ) -> None: - """Soft-delete (dismiss) a notification.""" + """Soft-delete (dismiss) a single notification.""" try: await notification_service.dismiss(session, notification_id, user.id) except ValueError as exc: @@ -145,3 +149,13 @@ async def dismiss_notification( status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found", ) from exc + + +@router.delete("", status_code=status.HTTP_200_OK) +async def clear_all_notifications( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> ClearAllResponse: + """Dismiss all notifications for the authenticated user.""" + cleared = await notification_service.dismiss_all(session, user.id) + return ClearAllResponse(cleared_count=cleared) diff --git a/apps/api/src/services/health_monitor.py b/apps/api/src/services/health_monitor.py index eec060f..9940abd 100644 --- a/apps/api/src/services/health_monitor.py +++ b/apps/api/src/services/health_monitor.py @@ -220,18 +220,18 @@ class HealthMonitor: await self._event_bus.publish(event_type, payload) # Create notification for instance owner (fire-and-forget) + # Only send warnings and errors; skip "recovered" info notifications. if new_status == "error": category = "instance" severity = "error" title = "Container failed" - else: + elif new_status == "unhealthy": category = "health" - if new_status == "unhealthy": - severity = "warning" - title = "Container unhealthy" - else: - severity = "info" - title = "Container recovered" + severity = "warning" + title = "Container unhealthy" + else: + # Running/recovered — do not notify + return try: await notification_service.create_notification( diff --git a/apps/api/src/services/lifecycle_hooks.py b/apps/api/src/services/lifecycle_hooks.py index 17c07e8..c3e802e 100644 --- a/apps/api/src/services/lifecycle_hooks.py +++ b/apps/api/src/services/lifecycle_hooks.py @@ -24,6 +24,7 @@ def _derive_title(event_type: str) -> str: "instance.restarted": "Container restarted", "instance.deleted": "Container deleted", "instance.error": "Container error", + "instance.health_changed": "Container ready", } return mapping.get( event_type, @@ -31,6 +32,21 @@ def _derive_title(event_type: str) -> str: ) +def _should_notify(event_type: str, status: str | None) -> bool: + """Determine whether a lifecycle event should generate a notification. + + Only warnings, errors, and "container is ready" (health_changed running) + are sent to users. + """ + if event_type == "instance.error": + return True + if event_type == "instance.health_changed" and status == "running": + return True + # Filter out: created, started, stopped, restarted, deleted, and any + # health_changed that is not "running" (unhealthy is handled by health_monitor) + return False + + def _build_payload( event_type: str, instance: ToolInstance, @@ -118,15 +134,16 @@ async def publish_lifecycle_event( await event_bus.publish(event_type, payload) # Create notification for instance owner (fire-and-forget) - # Skip intermediate "starting" notifications — only notify on terminal states - # (failed or successful attempts) - _is_starting_intermediate = event_type == "instance.started" and ( - status or instance.status - ) == "starting" - if _is_starting_intermediate: + # Only send warnings, errors, and "container is ready" notifications. + effective_status = status or instance.status + if not _should_notify(event_type, effective_status): return - severity = "error" if event_type == "instance.error" else "info" + severity = ( + "error" + if event_type == "instance.error" + else "success" + ) title = _derive_title(event_type) try: diff --git a/apps/api/src/services/notification_service.py b/apps/api/src/services/notification_service.py index e02d7cf..a6f4264 100644 --- a/apps/api/src/services/notification_service.py +++ b/apps/api/src/services/notification_service.py @@ -195,6 +195,32 @@ class NotificationService: await session.commit() return result.rowcount or 0 + async def dismiss_all( + self, + session: AsyncSession, + user_id: uuid.UUID, + ) -> int: + """Soft-delete all non-dismissed notifications for a user. + + Args: + session: Database session. + user_id: Owner of the notifications. + + Returns: + Number of rows updated. + """ + stmt = ( + update(Notification) + .where( + Notification.user_id == user_id, + Notification.dismissed_at.is_(None), + ) + .values(dismissed_at=datetime.now(timezone.utc)) + ) + result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment] + await session.commit() + return result.rowcount or 0 + async def dismiss( self, session: AsyncSession, diff --git a/apps/api/tests/unit/test_lifecycle_hooks.py b/apps/api/tests/unit/test_lifecycle_hooks.py new file mode 100644 index 0000000..af5676e --- /dev/null +++ b/apps/api/tests/unit/test_lifecycle_hooks.py @@ -0,0 +1,49 @@ +"""Unit tests for lifecycle hook helpers.""" + +import pytest + +from src.services.lifecycle_hooks import _derive_title, _should_notify + + +class TestDeriveTitle: + """Tests for _derive_title.""" + + def test_known_event_types(self) -> None: + assert _derive_title("instance.created") == "Container created" + assert _derive_title("instance.started") == "Container started" + assert _derive_title("instance.stopped") == "Container stopped" + assert _derive_title("instance.restarted") == "Container restarted" + assert _derive_title("instance.deleted") == "Container deleted" + assert _derive_title("instance.error") == "Container error" + assert _derive_title("instance.health_changed") == "Container ready" + + def test_unknown_event_type(self) -> None: + assert _derive_title("instance.custom_event") == "Custom Event" + + +class TestShouldNotify: + """Tests for _should_notify filtering.""" + + def test_error_events_are_notified(self) -> None: + assert _should_notify("instance.error", "error") is True + assert _should_notify("instance.error", None) is True + + def test_health_changed_running_is_notified(self) -> None: + assert _should_notify("instance.health_changed", "running") is True + + def test_created_started_stopped_restarted_deleted_filtered(self) -> None: + for event in [ + "instance.created", + "instance.started", + "instance.stopped", + "instance.restarted", + "instance.deleted", + ]: + assert _should_notify(event, "pending") is False + assert _should_notify(event, "running") is False + assert _should_notify(event, None) is False + + def test_health_changed_non_running_filtered(self) -> None: + assert _should_notify("instance.health_changed", "unhealthy") is False + assert _should_notify("instance.health_changed", "starting") is False + assert _should_notify("instance.health_changed", None) is False diff --git a/apps/api/tests/unit/test_notification_service.py b/apps/api/tests/unit/test_notification_service.py index 4088b45..d619e5e 100644 --- a/apps/api/tests/unit/test_notification_service.py +++ b/apps/api/tests/unit/test_notification_service.py @@ -308,6 +308,55 @@ async def test_get_unread_count_excludes_dismissed( assert count == 0 +@pytest.mark.unit +@pytest.mark.asyncio +async def test_dismiss_all_affects_all_non_dismissed( + db_session: AsyncSession, + notification_service: NotificationService, + user_a: User, +) -> None: + for i in range(4): + await notification_service.create_notification( + db_session, + user_a.id, + category="instance", + severity="info", + title=f"Notification {i}", + ) + + cleared = await notification_service.dismiss_all(db_session, user_a.id) + + assert cleared == 4 + items, total = await notification_service.list_notifications(db_session, user_a.id) + assert total == 0 + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_dismiss_all_affects_only_caller( + db_session: AsyncSession, + notification_service: NotificationService, + user_a: User, + user_b: User, +) -> None: + for i in range(3): + await notification_service.create_notification( + db_session, user_a.id, category="instance", severity="info", title=f"A-{i}" + ) + for i in range(2): + await notification_service.create_notification( + db_session, user_b.id, category="instance", severity="info", title=f"B-{i}" + ) + + cleared = await notification_service.dismiss_all(db_session, user_a.id) + + assert cleared == 3 + items_a, total_a = await notification_service.list_notifications(db_session, user_a.id) + items_b, total_b = await notification_service.list_notifications(db_session, user_b.id) + assert total_a == 0 + assert total_b == 2 + + @pytest.mark.unit @pytest.mark.asyncio async def test_mark_all_read_affects_only_caller( diff --git a/apps/web/src/api/notifications.ts b/apps/web/src/api/notifications.ts index 5b0189f..9865b25 100644 --- a/apps/web/src/api/notifications.ts +++ b/apps/web/src/api/notifications.ts @@ -30,6 +30,10 @@ export interface MarkAllReadResponse { marked_count: number; } +export interface ClearAllResponse { + cleared_count: number; +} + export const getNotifications = async (): Promise => { const response = await apiClient.get("/notifications"); @@ -62,3 +66,8 @@ export const markAllNotificationsRead = async (): Promise => { export const dismissNotification = async (id: string): Promise => { await apiClient.delete(`/notifications/${id}`); }; + +export const clearAllNotifications = async (): Promise => { + const response = await apiClient.delete("/notifications"); + return response.data.cleared_count; +}; diff --git a/apps/web/src/components/notification-center.test.tsx b/apps/web/src/components/notification-center.test.tsx index 9ef712c..780e7b8 100644 --- a/apps/web/src/components/notification-center.test.tsx +++ b/apps/web/src/components/notification-center.test.tsx @@ -9,6 +9,7 @@ vi.mock("../api/notifications", () => ({ markNotificationRead: vi.fn(), markAllNotificationsRead: vi.fn(), dismissNotification: vi.fn(), + clearAllNotifications: vi.fn(), })); import { getNotifications, getUnreadCount } from "../api/notifications"; @@ -146,6 +147,26 @@ describe("NotificationCenter", () => { expect(vi.mocked(mockMarkAll)).toHaveBeenCalled(); }); + it("calls clearAll on clear-all button click", async () => { + mockedGetNotifications.mockResolvedValue({ + items: [makeNotification("1")], + total: 1, + limit: 20, + offset: 0, + }); + + render(, { wrapper }); + fireEvent.click(screen.getByRole("button", { name: /notifications/i })); + + await vi.advanceTimersByTimeAsync(100); + fireEvent.click(screen.getByRole("button", { name: /clear all/i })); + + const { clearAllNotifications: mockClearAll } = await import( + "../api/notifications" + ); + expect(vi.mocked(mockClearAll)).toHaveBeenCalled(); + }); + it("refreshes list immediately on open", async () => { render(, { wrapper }); fireEvent.click(screen.getByRole("button", { name: /notifications/i })); diff --git a/apps/web/src/components/notification-center.tsx b/apps/web/src/components/notification-center.tsx index dda5667..dc5f431 100644 --- a/apps/web/src/components/notification-center.tsx +++ b/apps/web/src/components/notification-center.tsx @@ -15,6 +15,7 @@ export function NotificationCenter({ unreadCount, markRead, markAllRead, + clearAll, dismiss, refreshList, isDropdownOpen, @@ -115,6 +116,15 @@ export function NotificationCenter({ > Mark all as read + )} diff --git a/apps/web/src/state/notifications.tsx b/apps/web/src/state/notifications.tsx index c8a76b0..1bb17cb 100644 --- a/apps/web/src/state/notifications.tsx +++ b/apps/web/src/state/notifications.tsx @@ -11,6 +11,7 @@ import { markNotificationRead, markAllNotificationsRead, dismissNotification, + clearAllNotifications, } from "../api/notifications"; import type { NotificationItem } from "../api/notifications"; @@ -21,6 +22,7 @@ export interface NotificationContextValue { error: Error | null; markRead: (id: string) => Promise; markAllRead: () => Promise; + clearAll: () => Promise; dismiss: (id: string) => Promise; refreshList: () => Promise; isDropdownOpen: boolean; @@ -265,6 +267,24 @@ export function NotificationProvider({ await fetchList(); }, [fetchList]); + const clearAll = useCallback(async () => { + const { notifications: currentNotifications } = stateRef.current; + const unreadInList = currentNotifications.filter( + (n) => n.read_at === null, + ).length; + + setNotifications([]); + setUnreadCount((c) => Math.max(0, c - unreadInList)); + setError(null); + + try { + await clearAllNotifications(); + } catch (err) { + setNotifications(currentNotifications); + setError(err as Error); + } + }, []); + const value: NotificationContextValue = { notifications, unreadCount, @@ -272,6 +292,7 @@ export function NotificationProvider({ error, markRead, markAllRead, + clearAll, dismiss, refreshList, isDropdownOpen, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f6a512f..ff600d1 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -4602,10 +4602,12 @@ a:active, padding: 0.75rem 1rem; border-top: 1px solid var(--border); flex-shrink: 0; + display: flex; + gap: 0.5rem; } .notification-mark-all { - width: 100%; + flex: 1; padding: 0.5rem 0.75rem; background: transparent; border: 1px solid var(--border); @@ -4623,6 +4625,25 @@ a:active, border-color: var(--brand); } +.notification-clear-all { + flex: 1; + padding: 0.5rem 0.75rem; + background: transparent; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--muted); + font: inherit; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.notification-clear-all:hover { + background: var(--bg); + color: var(--danger); + border-color: var(--danger); +} + /* Notification Item */ .notification-item { display: flex; From eec37ab71045d6c81468d70d9f5ec1a081822d5c Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 16:49:22 +0200 Subject: [PATCH 03/10] fix: treat empty config_profile_id as no selection Frontend was sending empty string for config_profile_id when no profile was selected, causing 'not compatible' validation error. Backend now treats any falsy value (None, empty string) as 'no profile selected'. --- apps/api/src/api/tool_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 32ee7c1..62eabe1 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -475,7 +475,7 @@ async def _validate_config_profile( Raises: HTTPException: If profile is not found, not owned, or incompatible. """ - if profile_id is None: + if not profile_id: return None try: From 3d1f8d9cf7a52c3837739d9bae8e6770e42b235d Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 16:54:01 +0200 Subject: [PATCH 04/10] fix: reorder notification DELETE routes so bulk clear matches first FastAPI matches routes in declaration order. The DELETE /notifications endpoint (bulk clear) was registered AFTER DELETE /notifications/{id}, so the path parameter route intercepted all requests to the bulk route, causing a 422 UUID validation error instead of hitting clear_all. Moved clear_all_notifications above dismiss_notification in the router. Added regression test to verify route order. Quality gates: pytest (22 passed) --- apps/api/src/api/notifications.py | 20 +++++------ apps/api/src/services/lifecycle_hooks.py | 6 +--- .../tests/unit/test_notification_service.py | 8 +++-- .../unit/test_notifications_api_routes.py | 34 +++++++++++++++++++ 4 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 apps/api/tests/unit/test_notifications_api_routes.py diff --git a/apps/api/src/api/notifications.py b/apps/api/src/api/notifications.py index adaacb2..9dff52c 100644 --- a/apps/api/src/api/notifications.py +++ b/apps/api/src/api/notifications.py @@ -135,6 +135,16 @@ async def mark_all_read( return MarkAllReadResponse(marked_count=marked) +@router.delete("", status_code=status.HTTP_200_OK) +async def clear_all_notifications( + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_db_session), +) -> ClearAllResponse: + """Dismiss all notifications for the authenticated user.""" + cleared = await notification_service.dismiss_all(session, user.id) + return ClearAllResponse(cleared_count=cleared) + + @router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT) async def dismiss_notification( notification_id: uuid.UUID, @@ -149,13 +159,3 @@ async def dismiss_notification( status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found", ) from exc - - -@router.delete("", status_code=status.HTTP_200_OK) -async def clear_all_notifications( - user: User = Depends(get_current_user), - session: AsyncSession = Depends(get_db_session), -) -> ClearAllResponse: - """Dismiss all notifications for the authenticated user.""" - cleared = await notification_service.dismiss_all(session, user.id) - return ClearAllResponse(cleared_count=cleared) diff --git a/apps/api/src/services/lifecycle_hooks.py b/apps/api/src/services/lifecycle_hooks.py index c3e802e..5af816e 100644 --- a/apps/api/src/services/lifecycle_hooks.py +++ b/apps/api/src/services/lifecycle_hooks.py @@ -139,11 +139,7 @@ async def publish_lifecycle_event( if not _should_notify(event_type, effective_status): return - severity = ( - "error" - if event_type == "instance.error" - else "success" - ) + severity = "error" if event_type == "instance.error" else "success" title = _derive_title(event_type) try: diff --git a/apps/api/tests/unit/test_notification_service.py b/apps/api/tests/unit/test_notification_service.py index d619e5e..6aca22b 100644 --- a/apps/api/tests/unit/test_notification_service.py +++ b/apps/api/tests/unit/test_notification_service.py @@ -351,8 +351,12 @@ async def test_dismiss_all_affects_only_caller( cleared = await notification_service.dismiss_all(db_session, user_a.id) assert cleared == 3 - items_a, total_a = await notification_service.list_notifications(db_session, user_a.id) - items_b, total_b = await notification_service.list_notifications(db_session, user_b.id) + items_a, total_a = await notification_service.list_notifications( + db_session, user_a.id + ) + items_b, total_b = await notification_service.list_notifications( + db_session, user_b.id + ) assert total_a == 0 assert total_b == 2 diff --git a/apps/api/tests/unit/test_notifications_api_routes.py b/apps/api/tests/unit/test_notifications_api_routes.py new file mode 100644 index 0000000..e588456 --- /dev/null +++ b/apps/api/tests/unit/test_notifications_api_routes.py @@ -0,0 +1,34 @@ +"""Unit tests for notification API route ordering.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.notifications import router as notifications_router + + +def test_delete_notifications_route_order() -> None: + """DELETE /notifications must match before DELETE /notifications/{id}. + + FastAPI matches routes in declaration order. The bulk clear endpoint + (DELETE /notifications) must be registered before the single dismiss + endpoint (DELETE /notifications/{notification_id}) or the path + parameter route will intercept the bulk route. + """ + app = FastAPI() + app.include_router(notifications_router) + client = TestClient(app) + + # Verify the bulk delete route exists and returns the expected schema + # (it will 401 without auth, but that's fine — we just need to confirm + # routing doesn't hit the UUID-parameter route first) + response = client.delete("/notifications") + # Should get 401 (unauthenticated), NOT 422 (UUID parse error) + assert response.status_code == 401, ( + f"Expected 401 (auth required), got {response.status_code}. " + f"Route order may be wrong — DELETE /notifications matched " + f"DELETE /notifications/{{notification_id}} instead." + ) + + # Verify the single dismiss route still works (also 401 without auth) + response = client.delete("/notifications/12345678-1234-1234-1234-123456789abc") + assert response.status_code == 401 From 021537de56d4609f293de4395ebf7f8ddfd796ef Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 17:01:21 +0200 Subject: [PATCH 05/10] fix(cloudflared): replace broken --bind-addr at runtime + new migration Problem: The first migration already ran on the user's server with --bind-addr (broken). Alembic won't re-run the fixed migration. Changes: - _ensure_web_bind_address(): Now detects existing --bind-addr commands and replaces them with --host 0.0.0.0 instead of skipping - New migration 2026_05_29_fix_code_server_bind_addr: Finds code-server tool types with --bind-addr in compose_template and replaces with --host 0.0.0.0 Quality gates: pytest 42 passed (2 pre-existing unrelated failures) --- .../2026_05_29_fix_code_server_bind_addr.py | 53 +++++++++++++++++++ apps/api/src/api/tool_instances.py | 48 +++++++++++------ 2 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py diff --git a/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py b/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py new file mode 100644 index 0000000..a157b26 --- /dev/null +++ b/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py @@ -0,0 +1,53 @@ +"""fix code-server bind-addr to host in DB template + +Revision ID: 2026_05_29_fix_code_server_bind_addr +Revises: 2026_05_29_fix_web_tool_bind_address +Create Date: 2026-05-29 15:00:00.000000 + +""" +from typing import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "2026_05_29_fix_code_server_bind_addr" +down_revision: str | None = "2026_05_29_fix_web_tool_bind_address" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Find code-server tool types with broken --bind-addr in compose template + result = conn.execute( + sa.text(""" + SELECT id, compose_template + FROM tool_types + WHERE name = 'code-server' + AND compose_template LIKE '%--bind-addr%' + """) + ).fetchall() + + for tool_id, compose_template in result: + updated = compose_template.replace( + "--bind-addr 0.0.0.0:8443", "--host 0.0.0.0" + ).replace( + "--bind-addr", "--host 0.0.0.0" + ) + + conn.execute( + sa.text(""" + UPDATE tool_types + SET compose_template = :compose_template + WHERE id = :id + """), + {"compose_template": updated, "id": tool_id}, + ) + + print(f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host") + + +def downgrade() -> None: + pass diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 62eabe1..2dd4853 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -648,28 +648,46 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: return for service_config in compose_data["services"].values(): - # Skip if command is already overridden - if "command" in service_config: - return - image = service_config.get("image", "") if not image: - return + continue # Check if the image matches a known tool - if tool_type_name == "code-server" and ( + is_code_server = tool_type_name == "code-server" and ( "code-server" in image or "coder" in image - ): - service_config["command"] = bind_command - break - if tool_type_name == "jupyter-notebook" and ( + ) + is_jupyter = tool_type_name == "jupyter-notebook" and ( "jupyter" in image or "notebook" in image - ): - service_config["command"] = bind_command - break + ) + if not is_code_server and not is_jupyter: + continue - compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) - logger.info("Injected bind address for %s: %s", tool_type_name, bind_command) + existing_command = service_config.get("command", "") + if existing_command: + # Fix broken --bind-addr (replaces with --host) + if "--bind-addr" in existing_command: + service_config["command"] = bind_command + compose_file.write_text( + yaml.dump(compose_data, default_flow_style=False) + ) + logger.warning( + "Replaced broken bind address for %s: %s → %s", + tool_type_name, + existing_command, + bind_command, + ) + return + # Already has correct --host, nothing to do + if "--host" in existing_command or "--ip=" in existing_command: + return + # Some other command override exists — don't touch it + return + + # No command yet — inject the correct bind address + service_config["command"] = bind_command + compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) + logger.info("Injected bind address for %s: %s", tool_type_name, bind_command) + return @router.post( From a7a59058746fafa2d125ba51c1a9a70bc8d164b4 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 17:09:43 +0200 Subject: [PATCH 06/10] fix(cloudflared): remove command override for LSIO images Problem: linuxserver/code-server already binds to 0.0.0.0 by default. Adding any command: override (--bind-addr or --host) breaks the LSIO s6 init system with 'not found' errors. Changes: - _ensure_web_bind_address(): Skip LSIO images entirely (no command override needed). If an existing override is found, remove it. - New migration 2026_05_29_remove_lsio_command_override: Removes --bind-addr and --host command overrides from both DB templates and existing instance compose files on disk for LSIO images. Quality gates: ruff clean --- .../2026_05_29_fix_code_server_bind_addr.py | 9 +- ...2026_05_29_remove_lsio_command_override.py | 109 ++++++++++++++++++ apps/api/src/api/tool_instances.py | 15 +++ 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py diff --git a/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py b/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py index a157b26..cbca2ca 100644 --- a/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py +++ b/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py @@ -5,6 +5,7 @@ Revises: 2026_05_29_fix_web_tool_bind_address Create Date: 2026-05-29 15:00:00.000000 """ + from typing import Sequence from alembic import op @@ -33,9 +34,7 @@ def upgrade() -> None: for tool_id, compose_template in result: updated = compose_template.replace( "--bind-addr 0.0.0.0:8443", "--host 0.0.0.0" - ).replace( - "--bind-addr", "--host 0.0.0.0" - ) + ).replace("--bind-addr", "--host 0.0.0.0") conn.execute( sa.text(""" @@ -46,7 +45,9 @@ def upgrade() -> None: {"compose_template": updated, "id": tool_id}, ) - print(f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host") + print( + f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host" + ) def downgrade() -> None: diff --git a/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py b/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py new file mode 100644 index 0000000..cb220bf --- /dev/null +++ b/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py @@ -0,0 +1,109 @@ +"""Remove broken command override from LSIO code-server templates + +Revision ID: 2026_05_29_remove_lsio_command_override +Revises: 2026_05_29_fix_code_server_bind_addr +Create Date: 2026-05-29 15:05:00.000000 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "2026_05_29_remove_lsio_command_override" +down_revision: str | None = "2026_05_29_fix_code_server_bind_addr" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Find code-server tool types with broken command overrides + result = conn.execute( + sa.text(""" + SELECT id, compose_template + FROM tool_types + WHERE name = 'code-server' + """) + ).fetchall() + + import yaml + from pathlib import Path + + for tool_id, compose_template in result: + try: + data = yaml.safe_load(compose_template) + except Exception: + continue + + if not data or "services" not in data: + continue + + modified = False + for svc in data["services"].values(): + image = svc.get("image", "") + if not image or "linuxserver" not in image: + continue + if "command" in svc: + cmd = svc["command"] + if "--bind-addr" in cmd or "--host" in cmd: + del svc["command"] + modified = True + + if modified: + updated = yaml.dump(data, default_flow_style=False) + conn.execute( + sa.text(""" + UPDATE tool_types + SET compose_template = :compose_template + WHERE id = :id + """), + {"compose_template": updated, "id": tool_id}, + ) + print(f"Removed broken command override from LSIO template ({tool_id})") + + # Also clean up existing instance compose files on disk + result = conn.execute( + sa.text(""" + SELECT id, compose_file_path + FROM tool_instances + WHERE compose_file_path IS NOT NULL + """) + ).fetchall() + + for instance_id, compose_path in result: + path = Path(compose_path) + if not path.exists(): + continue + try: + content = path.read_text() + data = yaml.safe_load(content) + except Exception: + continue + + if not data or "services" not in data: + continue + + modified = False + for svc in data["services"].values(): + image = svc.get("image", "") + if not image or "linuxserver" not in image: + continue + if "command" in svc: + cmd = svc["command"] + if "--bind-addr" in cmd or "--host" in cmd: + del svc["command"] + modified = True + + if modified: + path.write_text(yaml.dump(data, default_flow_style=False)) + print( + f"Removed broken command override from instance compose " + f"({instance_id})" + ) + + +def downgrade() -> None: + pass diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 2dd4853..00912b8 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -652,6 +652,21 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: if not image: continue + # LSIO images already bind to 0.0.0.0 — command override breaks s6 init + if "linuxserver" in image: + existing_command = service_config.get("command", "") + if "--bind-addr" in existing_command or "--host" in existing_command: + del service_config["command"] + compose_file.write_text( + yaml.dump(compose_data, default_flow_style=False) + ) + logger.warning( + "Removed broken command override from LSIO image: %s", + existing_command, + ) + return + return + # Check if the image matches a known tool is_code_server = tool_type_name == "code-server" and ( "code-server" in image or "coder" in image From eef1e4e8c66fa9a7098788ad673dfd69f0d24538 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 17:17:12 +0200 Subject: [PATCH 07/10] fix(cloudflared): remove command override for LSIO images Problem: linuxserver/code-server already binds to 0.0.0.0 by default. Adding any command: override (--bind-addr or --host) breaks the LSIO s6 init system with 'not found' errors. Changes: - _ensure_web_bind_address(): Skip LSIO images entirely (no command override needed). If an existing override is found, remove it. - New migration 2026_05_29_remove_lsio_command_override: Removes --bind-addr and --host command overrides from both DB templates and existing instance compose files on disk for LSIO images. - Fixed migration to use correct column name (compose_path) and check information_schema for column existence defensively. Quality gates: ruff clean --- .../2026_05_29_fix_code_server_bind_addr.py | 9 +- ...2026_05_29_remove_lsio_command_override.py | 120 ++++++++++++++++++ apps/api/src/api/tool_instances.py | 15 +++ 3 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py diff --git a/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py b/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py index a157b26..cbca2ca 100644 --- a/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py +++ b/apps/api/alembic/versions/2026_05_29_fix_code_server_bind_addr.py @@ -5,6 +5,7 @@ Revises: 2026_05_29_fix_web_tool_bind_address Create Date: 2026-05-29 15:00:00.000000 """ + from typing import Sequence from alembic import op @@ -33,9 +34,7 @@ def upgrade() -> None: for tool_id, compose_template in result: updated = compose_template.replace( "--bind-addr 0.0.0.0:8443", "--host 0.0.0.0" - ).replace( - "--bind-addr", "--host 0.0.0.0" - ) + ).replace("--bind-addr", "--host 0.0.0.0") conn.execute( sa.text(""" @@ -46,7 +45,9 @@ def upgrade() -> None: {"compose_template": updated, "id": tool_id}, ) - print(f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host") + print( + f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host" + ) def downgrade() -> None: diff --git a/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py b/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py new file mode 100644 index 0000000..a951276 --- /dev/null +++ b/apps/api/alembic/versions/2026_05_29_remove_lsio_command_override.py @@ -0,0 +1,120 @@ +"""Remove broken command override from LSIO code-server templates + +Revision ID: 2026_05_29_remove_lsio_command_override +Revises: 2026_05_29_fix_code_server_bind_addr +Create Date: 2026-05-29 15:05:00.000000 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "2026_05_29_remove_lsio_command_override" +down_revision: str | None = "2026_05_29_fix_code_server_bind_addr" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Fix tool_types templates in DB + result = conn.execute( + sa.text(""" + SELECT id, compose_template + FROM tool_types + WHERE name = 'code-server' + """) + ).fetchall() + + import yaml + from pathlib import Path + + for tool_id, compose_template in result: + try: + data = yaml.safe_load(compose_template) + except Exception: + continue + + if not data or "services" not in data: + continue + + modified = False + for svc in data["services"].values(): + image = svc.get("image", "") + if not image or "linuxserver" not in image: + continue + if "command" in svc: + cmd = svc["command"] + if "--bind-addr" in cmd or "--host" in cmd: + del svc["command"] + modified = True + + if modified: + updated = yaml.dump(data, default_flow_style=False) + conn.execute( + sa.text(""" + UPDATE tool_types + SET compose_template = :compose_template + WHERE id = :id + """), + {"compose_template": updated, "id": tool_id}, + ) + print(f"Removed broken command override from LSIO template ({tool_id})") + + # Fix existing instance compose files on disk + # Use information_schema to check if compose_path column exists + col_result = conn.execute( + sa.text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'tool_instances' + AND column_name = 'compose_path' + """) + ).fetchone() + + if col_result: + result = conn.execute( + sa.text(""" + SELECT id, compose_path + FROM tool_instances + WHERE compose_path IS NOT NULL + """) + ).fetchall() + + for instance_id, compose_path in result: + path = Path(compose_path) + if not path.exists(): + continue + try: + content = path.read_text() + data = yaml.safe_load(content) + except Exception: + continue + + if not data or "services" not in data: + continue + + modified = False + for svc in data["services"].values(): + image = svc.get("image", "") + if not image or "linuxserver" not in image: + continue + if "command" in svc: + cmd = svc["command"] + if "--bind-addr" in cmd or "--host" in cmd: + del svc["command"] + modified = True + + if modified: + path.write_text(yaml.dump(data, default_flow_style=False)) + print( + f"Removed broken command override from instance compose " + f"({instance_id})" + ) + + +def downgrade() -> None: + pass diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 2dd4853..00912b8 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -652,6 +652,21 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: if not image: continue + # LSIO images already bind to 0.0.0.0 — command override breaks s6 init + if "linuxserver" in image: + existing_command = service_config.get("command", "") + if "--bind-addr" in existing_command or "--host" in existing_command: + del service_config["command"] + compose_file.write_text( + yaml.dump(compose_data, default_flow_style=False) + ) + logger.warning( + "Removed broken command override from LSIO image: %s", + existing_command, + ) + return + return + # Check if the image matches a known tool is_code_server = tool_type_name == "code-server" and ( "code-server" in image or "coder" in image From d713bfc5f903adbb21baffa1f590b77300ed2d42 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 17:20:15 +0200 Subject: [PATCH 08/10] fix: mobile terminal overlay status bar with auto-hide - Replace inline header+tabs layout with position:absolute overlay - Overlay contains: back button, session name, status dot, A-/A+ font size, exit - Session tabs live inside the overlay below the toolbar - Auto-hides after 3s; clicking terminal content hides it immediately - Pull handle at top edge appears when overlay is hidden to restore it - Terminal content always fills full viewport; overlay never resizes container - Pass showControls=false to TerminalComponent on mobile to avoid double headers --- apps/web/src/pages/terminal.tsx | 116 ++++++++++++++++------ apps/web/src/styles.css | 165 ++++++++++++++++++++++++++++---- 2 files changed, 235 insertions(+), 46 deletions(-) diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index 1d4f03a..09413b3 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -5,6 +5,7 @@ import { TerminalSessionTabs, type TerminalSessionInfo, } from "../components/terminal-session-tabs"; +import { Icon } from "../components/icon"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useAutoHide } from "../hooks/use-auto-hide"; import { useTerminalSessions } from "../hooks/use-terminal-sessions"; @@ -241,45 +242,99 @@ export const TerminalPage: React.FC = () => { const sessionInfos = SESSIONS_TO_INFO(sessions); if (isMobile) { + const activeSession = sessions.find((s) => s.id === activeSessionId); + const status = + terminalStatuses[activeSessionId ?? "default"] ?? "connecting"; + return (
+ {/* Overlay status bar — floats over terminal, never resizes it */}
headerAutoHide.show()} + className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`} + onClick={(e) => e.stopPropagation()} > - -

Terminal

- +
+
+ +
+
+ + {activeSession?.name || "Terminal"} + + +
+
+ + + +
+
+
+ +
+ + {/* Pull handle — visible when overlay is hidden */} + {!headerAutoHide.isVisible && ( + + )} + + {/* Terminal content — always fills full viewport */}
headerAutoHide.show()} + className="terminal-page-content mobile-full" + onClick={() => headerAutoHide.hide()} > - -
-
{error &&
{error}
} {sessions .filter((session) => session.id === activeSessionId) @@ -291,6 +346,7 @@ export const TerminalPage: React.FC = () => { sessionId={session.id} onClose={() => handleClose(session.id)} isMobile={true} + showControls={false} onTerminalReady={handleTerminalReady} />
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index ff600d1..5b536b1 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2950,42 +2950,175 @@ a.nav-item, background: #cd3131; } -/* Mobile auto-hide header and tabs */ -.terminal-page.mobile .terminal-page-header, -.mobile-tabs-container { +/* ============================================ + Mobile Terminal Overlay + ============================================ */ + +/* Mobile terminal page — no padding, terminal fills viewport */ +.terminal-page.mobile { + padding: 0; + gap: 0; + height: 100vh; + height: 100dvh; + position: relative; + overflow: hidden; +} + +/* Overlay status bar — floats over terminal, never resizes it */ +.mobile-terminal-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: #2d2d2d; + border-bottom: 1px solid #3e3e3e; transition: transform 0.3s ease, opacity 0.3s ease; } -.terminal-page.mobile .terminal-page-header.hidden, -.mobile-tabs-container.hidden { +.mobile-terminal-overlay.hidden { transform: translateY(-100%); opacity: 0; pointer-events: none; } -.terminal-page.mobile .terminal-page-header.visible, -.mobile-tabs-container.visible { +.mobile-terminal-overlay.visible { transform: translateY(0); opacity: 1; } +/* Toolbar row */ +.mobile-terminal-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2) var(--space-3); + gap: var(--space-2); +} + +.mobile-terminal-toolbar-left, +.mobile-terminal-toolbar-right { + display: flex; + align-items: center; + gap: var(--space-1); + flex: 0 0 auto; +} + +.mobile-terminal-toolbar-center { + display: flex; + align-items: center; + gap: var(--space-2); + flex: 1; + justify-content: center; + min-width: 0; +} + +.mobile-terminal-title { + font-size: 0.875rem; + font-weight: 500; + color: #d4d4d4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mobile-terminal-status { + width: 8px; + height: 8px; + border-radius: 50%; + background: #666; + flex-shrink: 0; +} + +.mobile-terminal-status.connecting { + background: #f5f543; + animation: pulse 1.5s infinite; +} + +.mobile-terminal-status.connected { + background: #0dbc79; +} + +.mobile-terminal-status.disconnected, +.mobile-terminal-status.error { + background: #cd3131; +} + +.mobile-terminal-toolbtn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: transparent; + border: 1px solid #3e3e3e; + border-radius: 6px; + color: #d4d4d4; + cursor: pointer; + font-size: 0.875rem; + transition: background 0.2s ease; +} + +.mobile-terminal-toolbtn:hover { + background: #3e3e3e; +} + +/* Session tabs inside overlay */ +.mobile-terminal-overlay-tabs { + background: #1e1e1e; + border-top: 1px solid #3e3e3e; +} + +.mobile-terminal-overlay-tabs .terminal-session-tabs { + background: #1e1e1e; + border-bottom: none; +} + +/* Pull handle — shown when overlay is hidden */ +.mobile-terminal-pull-handle { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + z-index: 99; + padding: 4px 16px; + background: #2d2d2d; + border: 1px solid #3e3e3e; + border-top: none; + border-radius: 0 0 12px 12px; + color: #888; + cursor: pointer; + font-size: 0.75rem; + line-height: 1; + transition: background 0.2s ease; +} + +.mobile-terminal-pull-handle:hover { + background: #3e3e3e; +} + +.mobile-terminal-pull-dots { + pointer-events: none; +} + +/* Terminal content — always fills full viewport on mobile */ +.terminal-page-content.mobile-full { + flex: 1; + min-height: 0; + border: none; + border-radius: 0; + overflow: hidden; +} + /* Mobile fullscreen */ @media (max-width: 767px) { .terminal-page.fullscreen { padding: 0; } - .terminal-page.mobile .terminal-page-header { - padding: var(--space-2); - gap: var(--space-2); - } - - .terminal-page.mobile .terminal-page-header h1 { - font-size: 1rem; - } - .terminal-session-tab-name { max-width: 80px; } From 946ac6f66a7630fa36452c081f9c147d737a4504 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 17:29:14 +0200 Subject: [PATCH 09/10] fix: remove mobile terminal pull handle, tap terminal to toggle overlay --- apps/web/src/pages/terminal.tsx | 17 +---------------- apps/web/src/styles.css | 27 --------------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index 09413b3..7e7b80b 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -315,25 +315,10 @@ export const TerminalPage: React.FC = () => { - {/* Pull handle — visible when overlay is hidden */} - {!headerAutoHide.isVisible && ( - - )} - {/* Terminal content — always fills full viewport */}
headerAutoHide.hide()} + onClick={() => headerAutoHide.toggle()} > {error &&
{error}
} {sessions diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 5b536b1..edd203a 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3077,33 +3077,6 @@ a.nav-item, border-bottom: none; } -/* Pull handle — shown when overlay is hidden */ -.mobile-terminal-pull-handle { - position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); - z-index: 99; - padding: 4px 16px; - background: #2d2d2d; - border: 1px solid #3e3e3e; - border-top: none; - border-radius: 0 0 12px 12px; - color: #888; - cursor: pointer; - font-size: 0.75rem; - line-height: 1; - transition: background 0.2s ease; -} - -.mobile-terminal-pull-handle:hover { - background: #3e3e3e; -} - -.mobile-terminal-pull-dots { - pointer-events: none; -} - /* Terminal content — always fills full viewport on mobile */ .terminal-page-content.mobile-full { flex: 1; From 97ebc1931388480f17173be507356cea8464c61d Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 17:36:08 +0200 Subject: [PATCH 10/10] fix: restore special keys bar on mobile terminal - Add SpecialKeysStrip and SpecialKeysPanel to mobile terminal page - Store sendData and focusInput refs via onTerminalReady callback - Pass activeModifier/onModifierChange to TerminalComponent on mobile - Add virtual keyboard padding to prevent keyboard from covering terminal - Special keys bar sits at bottom of viewport, panel opens as overlay --- apps/web/src/pages/terminal.tsx | 40 +++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index 7e7b80b..ab0abdd 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -6,10 +6,14 @@ import { type TerminalSessionInfo, } from "../components/terminal-session-tabs"; import { Icon } from "../components/icon"; +import { SpecialKeysStrip } from "../components/special-keys-strip"; +import { SpecialKeysPanel } from "../components/special-keys-panel"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useAutoHide } from "../hooks/use-auto-hide"; +import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard"; import { useTerminalSessions } from "../hooks/use-terminal-sessions"; import type { TerminalSession } from "../api/terminal"; +import type { ModifierKey } from "../hooks/use-special-keys"; const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] => sessions.map((s) => ({ @@ -40,7 +44,12 @@ export const TerminalPage: React.FC = () => { Record >({}); const changeFontSizeRef = useRef<((delta: number) => void) | null>(null); + const sendDataRef = useRef<((data: string) => void) | null>(null); + const focusInputRef = useRef<(() => void) | null>(null); const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false); + const [activeModifier, setActiveModifier] = useState(null); + const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard(); const { sessions, @@ -206,15 +215,17 @@ export const TerminalPage: React.FC = () => { const handleTerminalReady = useCallback( ( - _sendData: (data: string) => void, + sendData: (data: string) => void, status: TerminalStatus, - _focusInput: () => void, + focusInput: () => void, changeFontSize: (delta: number) => void, ) => { setTerminalStatuses((prev) => ({ ...prev, [activeSessionId ?? "default"]: status, })); + sendDataRef.current = sendData; + focusInputRef.current = focusInput; changeFontSizeRef.current = changeFontSize; }, [activeSessionId], @@ -224,6 +235,10 @@ export const TerminalPage: React.FC = () => { changeFontSizeRef.current?.(delta); }, []); + const handleSendKey = useCallback((data: string) => { + sendDataRef.current?.(data); + }, []); + const handleReset = useCallback(() => { if (activeSessionId && terminalRefs.current[activeSessionId]) { terminalRefs.current[activeSessionId].current?.reset(); @@ -318,6 +333,7 @@ export const TerminalPage: React.FC = () => { {/* Terminal content — always fills full viewport */}
headerAutoHide.toggle()} > {error &&
{error}
} @@ -332,6 +348,8 @@ export const TerminalPage: React.FC = () => { onClose={() => handleClose(session.id)} isMobile={true} showControls={false} + activeModifier={activeModifier} + onModifierChange={setActiveModifier} onTerminalReady={handleTerminalReady} />
@@ -342,6 +360,24 @@ export const TerminalPage: React.FC = () => {
)} + + setShowSpecialKeysPanel(true)} + onKeepFocus={() => focusInputRef.current?.()} + activeModifier={activeModifier} + onModifierChange={setActiveModifier} + /> + + setShowSpecialKeysPanel(false)} + onKeepFocus={() => focusInputRef.current?.()} + activeModifier={activeModifier} + onModifierChange={setActiveModifier} + />
); }