Compare commits

...

34 Commits

Author SHA1 Message Date
Alex Blank c051929f8c fix: use host bind mount for repos so tool instances can access workspace files
Replace named Docker volume (repo_data) with bind mount (/data/repos) in both
development and production compose files. The named volume trapped repo files
inside the API container; tool instances started via Docker socket on the host
could not see them, causing /workspace to mount as an empty directory.

Also fix 6 pre-existing test failures in test_tool_instances_legacy.py caused
by get_container_id/get_container_name moving to docker.py and new helpers
(_ensure_web_bind_address, _ensure_container_name_in_compose) being added.

- docker-compose.yml: repo_data:/data/repos -> /data/repos:/data/repos
- docker-compose.traefik.yml: same change + remove repo_data volume decl
- tests: update patch targets and add missing mock parameters

Quality gates: pytest test_tool_instances_legacy.py (10 passed)
2026-06-02 12:47:14 +02:00
Alex Blank 4814ec2363 fix: mobile terminal scroll in both normal mode and tmux
- Dual-mode touch scroll:
  - Normal mode: scroll .xterm-viewport directly when scrollHeight > clientHeight
  - Alternate screen (tmux/vim): send SGR 1006 mouse-wheel protocol data
    using cursor position so tmux knows which pane to scroll
- Add touch-action: none to .terminal-container to prevent browser gestures
- Lock both html and body overflow when terminal page is open on mobile
- Remove synthetic WheelEvent approach (xterm.js SmoothScrollableElement
  doesn't reliably handle synthetic events)
2026-05-29 20:35:28 +02:00
Alex Blank 98b9d612fa fix: container-level capture touch with direct viewport.scrollTop manipulation
- Attach capture-phase touch listeners to .terminal-container (parent of xterm)
- On vertical swipe: e.preventDefault() blocks page scroll, then directly
  adjust .xterm-viewport.scrollTop by the swipe delta
- This bypasses term.scrollLines() API and directly manipulates the DOM
  element that xterm.js watches via its internal scroll handler
- Remove all CSS touch-action overrides — container handles it in JS
2026-05-29 20:23:19 +02:00
Alex Blank 874873541d fix: xterm.js mobile touch scrolling via viewport CSS and stopPropagation
- Add full mobile viewport CSS: overflow-y scroll, -webkit-overflow-scrolling
  touch, overscroll-behavior-y contain, translate3d hardware accel,
  scroll-behavior smooth, touch-action pan-y
- After term.open(), find .xterm-viewport and add passive touch listeners
  that call stopPropagation() (not preventDefault) — this lets the browser
  handle native touch scrolling while preventing xterm.js internal handlers
  from interfering
- Based on xterm.js known issue #5489 and SCROLLING_FIX.md approach
2026-05-29 20:16:12 +02:00
Alex Blank ef9ac76f06 fix: remove all touch interception, let browser scroll xterm viewport natively
- xterm.js has zero touch event handlers (verified: only 1 'touch' ref in
  entire library), so it wasn't intercepting anything
- Our touch-action: none + preventDefault() combo was blocking the browser
  from scrolling the .xterm-viewport natively
- Removed all custom touch event handlers from terminal.tsx
- Removed touch-action: none from .terminal-container
- Added touch-action: pan-y to .xterm-viewport so browser allows vertical pan
- Body scroll lock (terminal-page-open) prevents page from scrolling
2026-05-29 20:08:58 +02:00
Alex Blank ca9db195de fix: document-level capture touch listeners for mobile terminal scroll
- Attach touch listeners to document with capture:true instead of container
- Check if touch target is inside terminal container before handling
- This runs before xterm.js internal handlers, giving us full control
- Add touch-action: none to terminal container to prevent browser gestures
- Lower threshold to 3px, 20px per line for responsive scrolling
2026-05-29 20:03:11 +02:00
Alex Blank c1e16f2163 fix: lock body scroll and re-add programmatic terminal touch scroll
- Add body.terminal-page-open { overflow: hidden } to prevent page scroll
- TerminalPage adds/removes 'terminal-page-open' class on body when mounted
- Re-add capture-phase touch listeners in terminal.tsx with low 3px threshold
- Call e.preventDefault() immediately when vertical gesture is detected,
  before browser compositor commits to page scroll
- Remove CSS touch-action overrides on xterm viewport (now handled in JS)
- Scroll forwarded via term.scrollLines() with 24px per line sensitivity
2026-05-29 19:57:48 +02:00
Alex Blank 61d32fa00f fix: enable native touch scrolling on xterm.js viewport for mobile
- Remove all custom touch event interception code from terminal.tsx
- After term.open(), find the internal .xterm-viewport element and set
  touchAction=pan-y and overscrollBehavior=contain via inline styles
- Add CSS targeting .xterm-viewport on mobile with touch-action: pan-y,
  -webkit-overflow-scrolling: touch, and overflow-y: auto
- Let the browser handle vertical touch panning natively instead of
  trying to intercept and manually forward events
2026-05-29 19:41:23 +02:00
Alex Blank cddb3f8ccf fix: mobile terminal scroll via capture-phase touch listeners
- Attach touch listeners to container wrapper in CAPTURE phase so they run
  before xterm.js internals stop propagation
- Add e.stopPropagation() in touchmove after handling scroll to prevent
  xterm.js from conflicting with our scroll
- Add wheel event fallback for mobile browsers that synthesize wheel from touch
- Remove touch-action: none CSS which was blocking native xterm viewport scroll
2026-05-29 19:35:42 +02:00
Alex Blank 87a938fe58 fix: mobile terminal touch scrolling direction and target
- Attach touch listeners to term.element (xterm root) instead of wrapper
- Fix scroll direction: swipe up now scrolls up (shows older buffer)
- Remove RAF indirection; scroll applied synchronously in touchmove
- Accumulate delta between events for smoother scrolling
- Lower threshold to 6px and px-per-line to 16 for better responsiveness
- Add touch-action: none to terminal container on mobile
2026-05-29 19:29:29 +02:00
Alex Blank 9157694412 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 19:21:35 +02:00
Alex Blank aa34314175 feat: touch swipe scrolling in terminal on mobile
- Intercepts touch events on the terminal container when isMobile=true
- Detects vertical swipe gestures (dominant over horizontal movement)
- Translates swipe distance to xterm.js scrollLines() calls
- Uses requestAnimationFrame for smooth scroll updates
- Threshold of 10px before scroll kicks in; 30px per line
- Touch listeners cleaned up on component unmount
2026-05-29 19:20:52 +02:00
Developer c7fc386d0f Merge branch 'fix/code-server-bind-addr-port' into dev 2026-05-29 16:50:00 +00:00
Developer 6bd814e346 fix(cloudflared): use --bind-addr with port for code-server bind fix
Root cause: _ensure_web_bind_address injected --host 0.0.0.0 for code-server,
which only sets the bind host, not the port. code-server then listens on its
default port (8080) instead of the tool type's default_port (8443). Cloudflared
connects to port 8443 and gets connection refused, resulting in a 502.

Changes:
- _ensure_web_bind_address now accepts default_port and builds
  --bind-addr 0.0.0.0:{port} for code-server
- Same fix for jupyter-notebook with explicit --port flag
- Existing broken --host commands are now detected and replaced
- New migration fixes tool_types templates and instance compose files on disk
- Test fixture updated to use correct --bind-addr 0.0.0.0:8443
2026-05-29 16:49:52 +00:00
alex aa25852091 fix: predictable container names for tunnel connectivity
- Inject explicit container_name into compose files at start/restart time
  via _ensure_container_name_in_compose() to prevent Docker Compose from
  generating UUID-based auto names that break backend network resolution.
- Use instance.name.lower() directly instead of get_container_name() lookups
  which were unreliable with auto-generated names.
- Apply compose sanitization, bind-address fix, and container-name injection
  on restart_instance as well so restarts pick up template fixes.
- Add --force-recreate to docker compose up to ensure container_name changes
  take effect immediately.
- Fix notification lifecycle tests to match current behavior (success severity,
  health_changed event for ownership test).

Quality gates: ruff clean, pytest (7 notification lifecycle tests passed)
2026-05-29 17:51:34 +02:00
Alex Blank c2740cd282 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 17:40:57 +02:00
Developer 23875bb3cc Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 15:40:36 +00:00
Developer ee1eab8408 Merge branch 'feat/agents-english-rule' into dev 2026-05-29 15:40:16 +00:00
Developer 2254ba7496 docs: add english language rule to AGENTS.md
- Require all agent output, comments, commits, docs, and artifacts to be in English unless explicitly requested otherwise
2026-05-29 15:40:11 +00:00
Alex Blank 4866ad08b1 feat: request screen wake lock while terminal page is open
- Uses navigator.wakeLock.request('screen') to keep device awake
- Re-acquires wake lock when tab becomes visible again
- Releases wake lock on component unmount
- Silently ignored on unsupported browsers or if denied
2026-05-29 17:39:57 +02:00
Alex Blank 97ebc19313 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
2026-05-29 17:36:08 +02:00
Alex Blank 946ac6f66a fix: remove mobile terminal pull handle, tap terminal to toggle overlay 2026-05-29 17:29:14 +02:00
Alex Blank 90ddee14c2 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 17:22:07 +02:00
Alex Blank f17f8ae8c8 Merge branch 'fix/mobile-terminal-overlay' into dev 2026-05-29 17:20:22 +02:00
Alex Blank d713bfc5f9 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
2026-05-29 17:20:15 +02:00
alex 27fe8c24ec merge: keep fixed migration with correct compose_path column 2026-05-29 17:18:17 +02:00
alex eef1e4e8c6 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 17:17:12 +02:00
alex a7a5905874 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 17:09:43 +02:00
alex 021537de56 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 17:02:26 +02:00
Alex Blank fdfd75790d Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 16:56:14 +02:00
Alex Blank 3d1f8d9cf7 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)
2026-05-29 16:54:01 +02:00
alex eec37ab710 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'.
2026-05-29 16:50:07 +02:00
Alex Blank 5f499ec1b0 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 16:48:24 +02:00
Alex Blank 2b5223097f 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)
2026-05-29 16:42:31 +02:00
25 changed files with 1170 additions and 140 deletions
+4
View File
@@ -4,6 +4,10 @@
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified. OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
## Communication
All agent output, code comments, commit messages, documentation, and artifacts must be in **English** unless the user explicitly requests another language.
## Priority order ## Priority order
1. Current user instruction 1. Current user instruction
@@ -0,0 +1,54 @@
"""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
@@ -0,0 +1,148 @@
"""Fix code-server bind address to include port
Revision ID: 2026_05_29_fix_code_server_bind_addr_port
Revises: 2026_05_29_remove_lsio_command_override
Create Date: 2026-05-29 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import yaml
# revision identifiers, used by Alembic.
revision: str = "2026_05_29_fix_code_server_bind_addr_port"
down_revision: Union[str, None] = "2026_05_29_remove_lsio_command_override"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _fix_tool_type_templates(conn) -> None:
"""Fix code-server tool type templates with broken --host override."""
result = conn.execute(
sa.text("""
SELECT id, compose_template, default_port
FROM tool_types
WHERE name = 'code-server'
AND compose_template LIKE '%--host%'
""")
).fetchall()
for tool_id, compose_template, default_port in result:
port = default_port or 8443
expected = f"--bind-addr 0.0.0.0:{port}"
# Replace any line containing --host with the correct bind-addr
lines = compose_template.split("\n")
new_lines = []
modified = False
for line in lines:
if "command:" in line and "--host" in line:
indent = line[: len(line) - len(line.lstrip())]
new_lines.append(f"{indent}command: {expected}")
modified = True
else:
new_lines.append(line)
if not modified:
continue
updated = "\n".join(new_lines)
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 --host with {expected}")
def _fix_instance_compose_files(conn) -> None:
"""Fix existing instance compose files on disk with broken --host override."""
from pathlib import Path
# 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 not col_result:
print("compose_path column not found, skipping instance file fixes")
return
result = conn.execute(
sa.text("""
SELECT id, compose_path, tool_type_id
FROM tool_instances
WHERE compose_path IS NOT NULL
""")
).fetchall()
for instance_id, compose_path, tool_type_id in result:
path = Path(compose_path)
if not path.exists():
continue
try:
content = path.read_text()
except Exception:
continue
if "--host" not in content:
continue
# Get default_port from tool_type
port_result = conn.execute(
sa.text("""
SELECT default_port FROM tool_types WHERE id = :id
"""),
{"id": tool_type_id},
).fetchone()
port = port_result[0] if port_result and port_result[0] else 8443
expected = f"--bind-addr 0.0.0.0:{port}"
try:
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():
if "command" in svc:
cmd = svc["command"]
if "--host" in cmd:
svc["command"] = expected
modified = True
if not modified:
continue
try:
path.write_text(yaml.dump(data, default_flow_style=False))
print(
f"Fixed code-server instance compose ({instance_id}): "
f"replaced --host with {expected}"
)
except Exception as exc:
print(f"Failed to fix instance {instance_id}: {exc}")
def upgrade() -> None:
conn = op.get_bind()
_fix_tool_type_templates(conn)
_fix_instance_compose_files(conn)
def downgrade() -> None:
pass
@@ -0,0 +1,121 @@
"""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
+15 -1
View File
@@ -47,6 +47,10 @@ class MarkAllReadResponse(BaseModel):
marked_count: int marked_count: int
class ClearAllResponse(BaseModel):
cleared_count: int
async def _get_mute_categories( async def _get_mute_categories(
session: AsyncSession, session: AsyncSession,
user_id: uuid.UUID, user_id: uuid.UUID,
@@ -131,13 +135,23 @@ async def mark_all_read(
return MarkAllReadResponse(marked_count=marked) 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) @router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
async def dismiss_notification( async def dismiss_notification(
notification_id: uuid.UUID, notification_id: uuid.UUID,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
) -> None: ) -> None:
"""Soft-delete (dismiss) a notification.""" """Soft-delete (dismiss) a single notification."""
try: try:
await notification_service.dismiss(session, notification_id, user.id) await notification_service.dismiss(session, notification_id, user.id)
except ValueError as exc: except ValueError as exc:
+124 -35
View File
@@ -52,7 +52,6 @@ from src.services.docker import (
find_free_port, find_free_port,
get_container_id, get_container_id,
get_container_logs, get_container_logs,
get_container_name,
get_container_status, get_container_status,
recreate_tunnel, recreate_tunnel,
render_compose_template, render_compose_template,
@@ -475,7 +474,7 @@ async def _validate_config_profile(
Raises: Raises:
HTTPException: If profile is not found, not owned, or incompatible. HTTPException: If profile is not found, not owned, or incompatible.
""" """
if profile_id is None: if not profile_id:
return None return None
try: try:
@@ -618,7 +617,44 @@ def _modify_compose_file(
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None: def _ensure_container_name_in_compose(compose_path: str, container_name: str) -> None:
"""Ensure compose file has explicit container_name for predictable naming.
Docker Compose auto-generates container names from the project directory
when container_name is absent. This breaks tunnel connectivity because
get_container_name(instance.name) cannot find the container. We inject
container_name into every service so the container has a predictable name.
"""
import yaml
from pathlib import Path
compose_file = Path(compose_path)
if not compose_file.exists():
return
content = compose_file.read_text()
compose_data = yaml.safe_load(content)
if not compose_data or "services" not in compose_data:
return
modified = False
for svc_name, svc_config in compose_data["services"].items():
if "container_name" not in svc_config:
svc_config["container_name"] = container_name.lower()
modified = True
if modified:
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
logger.info(
"Injected container_name '%s' into compose file",
container_name.lower(),
)
def _ensure_web_bind_address(
compose_path: str, tool_type_name: str, default_port: int
) -> None:
"""Auto-inject bind address for known web tools that default to 127.0.0.1. """Auto-inject bind address for known web tools that default to 127.0.0.1.
Many web tools (code-server, jupyter) bind to localhost by default, Many web tools (code-server, jupyter) bind to localhost by default,
@@ -628,9 +664,12 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
import yaml import yaml
from pathlib import Path from pathlib import Path
KNOWN_BIND_FIXES = { if default_port <= 0:
"code-server": "--host 0.0.0.0", return
"jupyter-notebook": "start-notebook.sh --ip=0.0.0.0",
KNOWN_BIND_FIXES: dict[str, str] = {
"code-server": f"--bind-addr 0.0.0.0:{default_port}",
"jupyter-notebook": f"start-notebook.sh --ip=0.0.0.0 --port={default_port} --no-browser",
} }
bind_command = KNOWN_BIND_FIXES.get(tool_type_name) bind_command = KNOWN_BIND_FIXES.get(tool_type_name)
@@ -648,28 +687,65 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
return return
for service_config in compose_data["services"].values(): 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", "") image = service_config.get("image", "")
if not image: 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 return
# Check if the image matches a known tool # 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 "code-server" in image or "coder" in image
): )
service_config["command"] = bind_command is_jupyter = tool_type_name == "jupyter-notebook" and (
break
if tool_type_name == "jupyter-notebook" and (
"jupyter" in image or "notebook" in image "jupyter" in image or "notebook" in image
): )
service_config["command"] = bind_command if not is_code_server and not is_jupyter:
break continue
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False)) existing_command = service_config.get("command", "")
logger.info("Injected bind address for %s: %s", tool_type_name, bind_command) if existing_command:
# Already correct — nothing to do
if bind_command in existing_command:
return
# Fix broken or outdated bind flags
if (
"--bind-addr" in existing_command
or "--host" in existing_command
or "--ip=" 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
# 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( @router.post(
@@ -1606,7 +1682,12 @@ async def start_instance(
# Auto-fix bind address for known web tools that default to localhost # Auto-fix bind address for known web tools that default to localhost
if tool_type and tool_type.interface_type == "web": if tool_type and tool_type.interface_type == "web":
_ensure_web_bind_address(instance.compose_path, tool_type.name) _ensure_web_bind_address(
instance.compose_path, tool_type.name, tool_type.default_port
)
# Ensure predictable container name for tunnel connectivity
_ensure_container_name_in_compose(instance.compose_path, instance.name)
# Execute docker compose up with env file # Execute docker compose up with env file
logger.debug( logger.debug(
@@ -1634,24 +1715,23 @@ async def start_instance(
detail=f"failed to start instance: {stderr}", detail=f"failed to start instance: {stderr}",
) )
# Get container ID and name # Get container ID and name (use predictable name from compose)
container_id = get_container_id(instance.name) expected_container_name = instance.name.lower()
container_id = get_container_id(expected_container_name)
if container_id: if container_id:
instance.container_id = container_id instance.container_id = container_id
logger.debug("Container ID for instance %s: %s", instance.id, container_id) logger.debug("Container ID for instance %s: %s", instance.id, container_id)
container_name = get_container_name(instance.name) instance.container_name = expected_container_name
if container_name: logger.debug("Container name for instance %s: %s", instance.id, expected_container_name)
instance.container_name = container_name
logger.debug("Container name for instance %s: %s", instance.id, container_name)
# Connect container to backend network so API can reach it # Connect container to backend network so API can reach it
logger.debug("Connecting container %s to backend network...", container_name) logger.debug("Connecting container %s to backend network...", expected_container_name)
connected = connect_container_to_network(container_name, "backend") connected = connect_container_to_network(expected_container_name, "backend")
if connected: if connected:
logger.debug("Successfully connected %s to backend network", container_name) logger.debug("Successfully connected %s to backend network", expected_container_name)
else: else:
logger.warning("Failed to connect %s to backend network", container_name) logger.warning("Failed to connect %s to backend network", expected_container_name)
# Verify container reached running state # Verify container reached running state
if instance.container_id: if instance.container_id:
@@ -2078,6 +2158,15 @@ async def restart_instance(
exc, exc,
) )
# Re-apply compose fixes in case they were updated since last start
_sanitize_compose_file(instance.compose_path)
tool_type = await session.get(ToolType, instance.tool_type_id)
if tool_type and tool_type.interface_type == "web":
_ensure_web_bind_address(
instance.compose_path, tool_type.name, tool_type.default_port
)
_ensure_container_name_in_compose(instance.compose_path, instance.name)
returncode, stdout, stderr = execute_compose_command( returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "restart" instance.compose_path, "restart"
) )
@@ -2107,7 +2196,7 @@ async def restart_instance(
# Create new temporary tunnel # Create new temporary tunnel
try: try:
tunnel_info = start_cloudflared_tunnel( tunnel_info = start_cloudflared_tunnel(
container_name=instance.container_name or instance.name, container_name=instance.name.lower(),
port=instance_port, port=instance_port,
) )
instance.tunnel_id = tunnel_info["pid"] instance.tunnel_id = tunnel_info["pid"]
+1 -1
View File
@@ -159,7 +159,7 @@ def execute_compose_command(
cmd.extend(["--env-file", env_file]) cmd.extend(["--env-file", env_file])
if action == "up": if action == "up":
cmd.extend(["up", "-d"]) cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down": elif action == "down":
cmd.extend(["down", "-v"]) cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"): elif action in ("start", "stop", "restart"):
+7 -7
View File
@@ -220,18 +220,18 @@ class HealthMonitor:
await self._event_bus.publish(event_type, payload) await self._event_bus.publish(event_type, payload)
# Create notification for instance owner (fire-and-forget) # Create notification for instance owner (fire-and-forget)
# Only send warnings and errors; skip "recovered" info notifications.
if new_status == "error": if new_status == "error":
category = "instance" category = "instance"
severity = "error" severity = "error"
title = "Container failed" title = "Container failed"
else: elif new_status == "unhealthy":
category = "health" category = "health"
if new_status == "unhealthy": severity = "warning"
severity = "warning" title = "Container unhealthy"
title = "Container unhealthy" else:
else: # Running/recovered — do not notify
severity = "info" return
title = "Container recovered"
try: try:
await notification_service.create_notification( await notification_service.create_notification(
+20 -7
View File
@@ -24,6 +24,7 @@ def _derive_title(event_type: str) -> str:
"instance.restarted": "Container restarted", "instance.restarted": "Container restarted",
"instance.deleted": "Container deleted", "instance.deleted": "Container deleted",
"instance.error": "Container error", "instance.error": "Container error",
"instance.health_changed": "Container ready",
} }
return mapping.get( return mapping.get(
event_type, 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( def _build_payload(
event_type: str, event_type: str,
instance: ToolInstance, instance: ToolInstance,
@@ -118,15 +134,12 @@ async def publish_lifecycle_event(
await event_bus.publish(event_type, payload) await event_bus.publish(event_type, payload)
# Create notification for instance owner (fire-and-forget) # Create notification for instance owner (fire-and-forget)
# Skip intermediate "starting" notifications — only notify on terminal states # Only send warnings, errors, and "container is ready" notifications.
# (failed or successful attempts) effective_status = status or instance.status
_is_starting_intermediate = event_type == "instance.started" and ( if not _should_notify(event_type, effective_status):
status or instance.status
) == "starting"
if _is_starting_intermediate:
return return
severity = "error" if event_type == "instance.error" else "info" severity = "error" if event_type == "instance.error" else "success"
title = _derive_title(event_type) title = _derive_title(event_type)
try: try:
@@ -195,6 +195,32 @@ class NotificationService:
await session.commit() await session.commit()
return result.rowcount or 0 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( async def dismiss(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -149,8 +149,8 @@ async def test_lifecycle_running_creates_notification(
assert len(notifications) == 1 assert len(notifications) == 1
n = notifications[0] n = notifications[0]
assert n.category == "instance" assert n.category == "instance"
assert n.severity == "info" assert n.severity == "success"
assert n.title == "Health Changed" assert n.title == "Container ready"
assert n.source_type == "tool_instances" assert n.source_type == "tool_instances"
assert n.source_id == test_instance.id assert n.source_id == test_instance.id
@@ -315,9 +315,9 @@ async def test_notification_ownership_matches_instance_owner(
event_bus=event_bus, event_bus=event_bus,
session=db_session, session=db_session,
instance=instance, instance=instance,
event_type="instance.created", event_type="instance.health_changed",
status="pending", status="running",
message="Instance created", message="Container running",
) )
result = await db_session.execute( result = await db_session.execute(
@@ -184,7 +184,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web", "terminal"], "interfaces": ["web", "terminal"],
"default_port": 8443, "default_port": 8443,
"definition_type": "compose", "definition_type": "compose",
"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\"", "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\"",
"readiness_probe": { "readiness_probe": {
"command": "curl -f http://localhost:8443", "command": "curl -f http://localhost:8443",
"timeout": 30, "timeout": 30,
@@ -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
@@ -308,6 +308,59 @@ async def test_get_unread_count_excludes_dismissed(
assert count == 0 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.unit
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mark_all_read_affects_only_caller( async def test_mark_all_read_affects_only_caller(
@@ -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
@@ -411,8 +411,9 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.wait_for_container_running") @patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._prepare_manifest_instance") @patch("src.api.tool_instances._prepare_manifest_instance")
@patch("src.api.tool_instances._get_user") @patch("src.api.tool_instances._get_user")
@@ -423,8 +424,9 @@ class TestStartInstanceLegacyFallback:
mock_get_user, mock_get_user,
mock_prepare_manifest, mock_prepare_manifest,
mock_sanitize, mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network, mock_connect_network,
mock_get_container_name,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
mock_wait_container, mock_wait_container,
@@ -440,7 +442,6 @@ class TestStartInstanceLegacyFallback:
mock_get_project.return_value = AsyncMock() mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "") mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123" mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True mock_connect_network.return_value = True
mock_wait_container.return_value = { mock_wait_container.return_value = {
"success": True, "success": True,
@@ -509,8 +510,9 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.wait_for_container_running") @patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._prepare_manifest_instance") @patch("src.api.tool_instances._prepare_manifest_instance")
@patch("src.api.tool_instances._get_user") @patch("src.api.tool_instances._get_user")
@@ -521,8 +523,9 @@ class TestStartInstanceLegacyFallback:
mock_get_user, mock_get_user,
mock_prepare_manifest, mock_prepare_manifest,
mock_sanitize, mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network, mock_connect_network,
mock_get_container_name,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
mock_wait_container, mock_wait_container,
@@ -538,7 +541,6 @@ class TestStartInstanceLegacyFallback:
mock_get_project.return_value = AsyncMock() mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "") mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123" mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True mock_connect_network.return_value = True
mock_wait_container.return_value = { mock_wait_container.return_value = {
"success": True, "success": True,
@@ -606,8 +608,9 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.wait_for_container_running") @patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._prepare_manifest_instance") @patch("src.api.tool_instances._prepare_manifest_instance")
@patch("src.api.tool_instances._get_user") @patch("src.api.tool_instances._get_user")
@@ -618,8 +621,9 @@ class TestStartInstanceLegacyFallback:
mock_get_user, mock_get_user,
mock_prepare_manifest, mock_prepare_manifest,
mock_sanitize, mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network, mock_connect_network,
mock_get_container_name,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
mock_wait_container, mock_wait_container,
@@ -635,7 +639,6 @@ class TestStartInstanceLegacyFallback:
mock_get_project.return_value = AsyncMock() mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "") mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123" mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True mock_connect_network.return_value = True
mock_wait_container.return_value = { mock_wait_container.return_value = {
"success": True, "success": True,
@@ -705,12 +708,14 @@ class TestStartInstanceSshPermissions:
"""SSH key mounts trigger permission fixes after container starts.""" """SSH key mounts trigger permission fixes after container starts."""
@patch("src.api.tool_instances.write_compose_file") @patch("src.api.tool_instances.write_compose_file")
@patch("src.api.tool_instances.prepare_ssh_key_files")
@patch("src.api.tool_instances.apply_ssh_permissions") @patch("src.api.tool_instances.apply_ssh_permissions")
@patch("src.api.tool_instances.wait_for_container_running") @patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._get_user") @patch("src.api.tool_instances._get_user")
@patch("src.api.tool_instances._get_owned_project") @patch("src.api.tool_instances._get_owned_project")
@@ -719,12 +724,14 @@ class TestStartInstanceSshPermissions:
mock_get_project, mock_get_project,
mock_get_user, mock_get_user,
mock_sanitize, mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network, mock_connect_network,
mock_get_container_name,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
mock_wait_container, mock_wait_container,
mock_apply_ssh, mock_apply_ssh,
mock_prepare_ssh,
mock_write_compose, mock_write_compose,
mock_session, mock_session,
fake_user_id, fake_user_id,
@@ -743,7 +750,6 @@ class TestStartInstanceSshPermissions:
mock_get_project.return_value = AsyncMock() mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "") mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123" mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True mock_connect_network.return_value = True
mock_wait_container.return_value = { mock_wait_container.return_value = {
"success": True, "success": True,
@@ -836,12 +842,14 @@ class TestStartInstanceSshPermissions:
assert result["status"] == "running" assert result["status"] == "running"
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user") mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
@patch("src.api.tool_instances.prepare_ssh_key_files")
@patch("src.api.tool_instances.apply_ssh_permissions") @patch("src.api.tool_instances.apply_ssh_permissions")
@patch("src.api.tool_instances.wait_for_container_running") @patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._get_user") @patch("src.api.tool_instances._get_user")
@patch("src.api.tool_instances._get_owned_project") @patch("src.api.tool_instances._get_owned_project")
@@ -850,12 +858,14 @@ class TestStartInstanceSshPermissions:
mock_get_project, mock_get_project,
mock_get_user, mock_get_user,
mock_sanitize, mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network, mock_connect_network,
mock_get_container_name,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
mock_wait_container, mock_wait_container,
mock_apply_ssh, mock_apply_ssh,
mock_prepare_ssh,
mock_session, mock_session,
fake_user_id, fake_user_id,
fake_project_id, fake_project_id,
@@ -870,7 +880,6 @@ class TestStartInstanceSshPermissions:
mock_get_project.return_value = AsyncMock() mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "") mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123" mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True mock_connect_network.return_value = True
mock_wait_container.return_value = { mock_wait_container.return_value = {
"success": True, "success": True,
@@ -933,14 +942,15 @@ class TestStartInstanceSshPermissions:
mock_session.get.side_effect = _get mock_session.get.side_effect = _get
with patch("os.path.exists", return_value=True): with patch("os.path.exists", return_value=True):
result = await start_instance( with patch("src.api.tool_instances._modify_compose_file"):
project_id=fake_project_id, result = await start_instance(
repo_id=fake_repo_id, project_id=fake_project_id,
instance_id=fake_instance_id, repo_id=fake_repo_id,
data=None, instance_id=fake_instance_id,
user_id=fake_user_id, data=None,
session=mock_session, user_id=fake_user_id,
) session=mock_session,
)
assert result["status"] == "running" assert result["status"] == "running"
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root") mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
@@ -952,8 +962,9 @@ class TestStartInstanceManifestBranch:
@patch("src.api.tool_instances.wait_for_container_running") @patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command") @patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id") @patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network") @patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file") @patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._prepare_manifest_instance") @patch("src.api.tool_instances._prepare_manifest_instance")
@patch("src.api.tool_instances.write_compose_file") @patch("src.api.tool_instances.write_compose_file")
@@ -966,8 +977,9 @@ class TestStartInstanceManifestBranch:
mock_write_compose, mock_write_compose,
mock_prepare_manifest, mock_prepare_manifest,
mock_sanitize, mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network, mock_connect_network,
mock_get_container_name,
mock_get_container_id, mock_get_container_id,
mock_execute_compose, mock_execute_compose,
mock_wait_container, mock_wait_container,
@@ -987,7 +999,6 @@ class TestStartInstanceManifestBranch:
mock_get_project.return_value = AsyncMock() mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "") mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123" mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True mock_connect_network.return_value = True
mock_wait_container.return_value = { mock_wait_container.return_value = {
"success": True, "success": True,
+9
View File
@@ -30,6 +30,10 @@ export interface MarkAllReadResponse {
marked_count: number; marked_count: number;
} }
export interface ClearAllResponse {
cleared_count: number;
}
export const getNotifications = async (): Promise<NotificationListResponse> => { export const getNotifications = async (): Promise<NotificationListResponse> => {
const response = const response =
await apiClient.get<NotificationListResponse>("/notifications"); await apiClient.get<NotificationListResponse>("/notifications");
@@ -62,3 +66,8 @@ export const markAllNotificationsRead = async (): Promise<number> => {
export const dismissNotification = async (id: string): Promise<void> => { export const dismissNotification = async (id: string): Promise<void> => {
await apiClient.delete(`/notifications/${id}`); await apiClient.delete(`/notifications/${id}`);
}; };
export const clearAllNotifications = async (): Promise<number> => {
const response = await apiClient.delete<ClearAllResponse>("/notifications");
return response.data.cleared_count;
};
@@ -9,6 +9,7 @@ vi.mock("../api/notifications", () => ({
markNotificationRead: vi.fn(), markNotificationRead: vi.fn(),
markAllNotificationsRead: vi.fn(), markAllNotificationsRead: vi.fn(),
dismissNotification: vi.fn(), dismissNotification: vi.fn(),
clearAllNotifications: vi.fn(),
})); }));
import { getNotifications, getUnreadCount } from "../api/notifications"; import { getNotifications, getUnreadCount } from "../api/notifications";
@@ -146,6 +147,26 @@ describe("NotificationCenter", () => {
expect(vi.mocked(mockMarkAll)).toHaveBeenCalled(); 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(<NotificationCenter />, { 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 () => { it("refreshes list immediately on open", async () => {
render(<NotificationCenter />, { wrapper }); render(<NotificationCenter />, { wrapper });
fireEvent.click(screen.getByRole("button", { name: /notifications/i })); fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
@@ -15,6 +15,7 @@ export function NotificationCenter({
unreadCount, unreadCount,
markRead, markRead,
markAllRead, markAllRead,
clearAll,
dismiss, dismiss,
refreshList, refreshList,
isDropdownOpen, isDropdownOpen,
@@ -115,6 +116,15 @@ export function NotificationCenter({
> >
Mark all as read Mark all as read
</button> </button>
<button
type="button"
className="notification-clear-all"
onClick={() => {
void clearAll();
}}
>
Clear all
</button>
</div> </div>
)} )}
</div> </div>
+94 -1
View File
@@ -313,6 +313,96 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.focus(); term.focus();
const ws = connectWebSocket(); const ws = connectWebSocket();
// Mobile touch scroll.
// In normal mode xterm.js has a scrollable viewport; in alternate
// screen (tmux/vim) there is no scrollback and the only way to
// scroll is to send mouse-wheel protocol sequences to the
// application. We detect which situation we're in by checking
// whether the viewport has scrollable height.
let touchCleanup: (() => void) | undefined;
if (isMobile) {
let startY = 0;
let startX = 0;
let isScrolling = false;
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
isScrolling = false;
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaY = startY - touch.clientY;
const deltaX = Math.abs(startX - touch.clientX);
if (!isScrolling) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
isScrolling = true;
}
}
if (isScrolling) {
e.preventDefault();
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (!viewport) return;
// If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data.
const hasScrollback =
viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) {
viewport.scrollTop += deltaY;
} else {
const ws = wsRef.current;
if (
ws?.readyState === WebSocket.OPEN &&
termRef.current
) {
// Use the cursor position as the wheel location so
// tmux knows which pane to scroll.
const buf = termRef.current.buffer.active;
const col = buf.cursorX + 1;
const row = buf.cursorY + 1;
// SGR 1006: 64 = wheel-up, 65 = wheel-down
const btn = deltaY > 0 ? 64 : 65;
ws.send(`\x1b[<${btn};${col};${row}M`);
}
}
startY = touch.clientY;
}
};
const onTouchEnd = () => {
isScrolling = false;
};
container.addEventListener("touchstart", onTouchStart, {
passive: true,
capture: true,
});
container.addEventListener("touchmove", onTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", onTouchEnd, {
capture: true,
});
touchCleanup = () => {
container.removeEventListener("touchstart", onTouchStart, {
capture: true,
});
container.removeEventListener("touchmove", onTouchMove, {
capture: true,
});
container.removeEventListener("touchend", onTouchEnd, {
capture: true,
});
};
}
// Initial fit after layout settles (terminal must be opened first) // Initial fit after layout settles (terminal must be opened first)
let fitAttempts = 0; let fitAttempts = 0;
const doInitialFit = () => { const doInitialFit = () => {
@@ -440,6 +530,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
"visibilitychange", "visibilitychange",
handleVisibilityChange, handleVisibilityChange,
); );
if (touchCleanup) touchCleanup();
if (ws) { if (ws) {
ws.close(1000, "Component unmounting"); ws.close(1000, "Component unmounting");
} }
@@ -568,7 +659,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
}; };
return ( return (
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}> <div
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
>
{showControls && ( {showControls && (
<div className="terminal-header"> <div className="terminal-header">
<div className="terminal-header-left"> <div className="terminal-header-left">
+153 -32
View File
@@ -5,10 +5,15 @@ import {
TerminalSessionTabs, TerminalSessionTabs,
type TerminalSessionInfo, type TerminalSessionInfo,
} from "../components/terminal-session-tabs"; } 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 { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useAutoHide } from "../hooks/use-auto-hide"; import { useAutoHide } from "../hooks/use-auto-hide";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useTerminalSessions } from "../hooks/use-terminal-sessions"; import { useTerminalSessions } from "../hooks/use-terminal-sessions";
import type { TerminalSession } from "../api/terminal"; import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "../hooks/use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] => const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({ sessions.map((s) => ({
@@ -39,7 +44,15 @@ export const TerminalPage: React.FC = () => {
Record<string, TerminalStatus> Record<string, TerminalStatus>
>({}); >({});
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null); 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 [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const { const {
sessions, sessions,
@@ -162,6 +175,47 @@ export const TerminalPage: React.FC = () => {
setActiveSessionId, setActiveSessionId,
]); ]);
// Keep screen awake while terminal is open
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// Wake lock may be denied; silently ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void requestWakeLock();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
// not the page.
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
// Click outside terminal content/header to exit fullscreen // Click outside terminal content/header to exit fullscreen
const handleFullscreenClick = useCallback( const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => { (e: React.MouseEvent<HTMLElement>) => {
@@ -205,15 +259,17 @@ export const TerminalPage: React.FC = () => {
const handleTerminalReady = useCallback( const handleTerminalReady = useCallback(
( (
_sendData: (data: string) => void, sendData: (data: string) => void,
status: TerminalStatus, status: TerminalStatus,
_focusInput: () => void, focusInput: () => void,
changeFontSize: (delta: number) => void, changeFontSize: (delta: number) => void,
) => { ) => {
setTerminalStatuses((prev) => ({ setTerminalStatuses((prev) => ({
...prev, ...prev,
[activeSessionId ?? "default"]: status, [activeSessionId ?? "default"]: status,
})); }));
sendDataRef.current = sendData;
focusInputRef.current = focusInput;
changeFontSizeRef.current = changeFontSize; changeFontSizeRef.current = changeFontSize;
}, },
[activeSessionId], [activeSessionId],
@@ -223,6 +279,10 @@ export const TerminalPage: React.FC = () => {
changeFontSizeRef.current?.(delta); changeFontSizeRef.current?.(delta);
}, []); }, []);
const handleSendKey = useCallback((data: string) => {
sendDataRef.current?.(data);
}, []);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) { if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset(); terminalRefs.current[activeSessionId].current?.reset();
@@ -241,45 +301,85 @@ export const TerminalPage: React.FC = () => {
const sessionInfos = SESSIONS_TO_INFO(sessions); const sessionInfos = SESSIONS_TO_INFO(sessions);
if (isMobile) { if (isMobile) {
const activeSession = sessions.find((s) => s.id === activeSessionId);
const status =
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
return ( return (
<section <section
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`} className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
> >
{/* Overlay status bar — floats over terminal, never resizes it */}
<div <div
className={`terminal-page-header mobile-header ${headerAutoHide.isVisible ? "visible" : "hidden"}`} className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={() => headerAutoHide.show()} onClick={(e) => e.stopPropagation()}
> >
<button <div className="mobile-terminal-toolbar">
className="secondary-button" <div className="mobile-terminal-toolbar-left">
onClick={() => navigate(-1)} <button
type="button" className="mobile-terminal-toolbtn"
> onClick={() => navigate(-1)}
Back type="button"
</button> aria-label="Back"
<h1>Terminal</h1> >
<button <Icon name="arrow-left" size="sm" />
className="secondary-button" </button>
onClick={() => setIsFullscreen((p) => !p)} </div>
type="button" <div className="mobile-terminal-toolbar-center">
> <span className="mobile-terminal-title">
{isFullscreen ? "Exit" : "Fullscreen"} {activeSession?.name || "Terminal"}
</button> </span>
<span
className={`mobile-terminal-status status-dot ${status}`}
aria-label={`Connection status: ${status}`}
/>
</div>
<div className="mobile-terminal-toolbar-right">
<button
className="mobile-terminal-toolbtn"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button
className="mobile-terminal-toolbtn"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
<button
className="mobile-terminal-toolbtn"
onClick={() => navigate(-1)}
type="button"
aria-label="Exit terminal"
>
<Icon name="close" size="sm" />
</button>
</div>
</div>
<div className="mobile-terminal-overlay-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
</div>
</div> </div>
{/* Terminal content — always fills full viewport */}
<div <div
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`} className="terminal-page-content mobile-full"
onClick={() => headerAutoHide.show()} style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
onClick={() => headerAutoHide.toggle()}
> >
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
</div>
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>} {error && <div className="terminal-error-banner">{error}</div>}
{sessions {sessions
.filter((session) => session.id === activeSessionId) .filter((session) => session.id === activeSessionId)
@@ -291,6 +391,9 @@ export const TerminalPage: React.FC = () => {
sessionId={session.id} sessionId={session.id}
onClose={() => handleClose(session.id)} onClose={() => handleClose(session.id)}
isMobile={true} isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady} onTerminalReady={handleTerminalReady}
/> />
</div> </div>
@@ -301,6 +404,24 @@ export const TerminalPage: React.FC = () => {
</div> </div>
)} )}
</div> </div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={!showSpecialKeysPanel}
onMoreClick={() => setShowSpecialKeysPanel(true)}
onKeepFocus={() => focusInputRef.current?.()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showSpecialKeysPanel}
onClose={() => setShowSpecialKeysPanel(false)}
onKeepFocus={() => focusInputRef.current?.()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</section> </section>
); );
} }
+21
View File
@@ -11,6 +11,7 @@ import {
markNotificationRead, markNotificationRead,
markAllNotificationsRead, markAllNotificationsRead,
dismissNotification, dismissNotification,
clearAllNotifications,
} from "../api/notifications"; } from "../api/notifications";
import type { NotificationItem } from "../api/notifications"; import type { NotificationItem } from "../api/notifications";
@@ -21,6 +22,7 @@ export interface NotificationContextValue {
error: Error | null; error: Error | null;
markRead: (id: string) => Promise<void>; markRead: (id: string) => Promise<void>;
markAllRead: () => Promise<void>; markAllRead: () => Promise<void>;
clearAll: () => Promise<void>;
dismiss: (id: string) => Promise<void>; dismiss: (id: string) => Promise<void>;
refreshList: () => Promise<void>; refreshList: () => Promise<void>;
isDropdownOpen: boolean; isDropdownOpen: boolean;
@@ -265,6 +267,24 @@ export function NotificationProvider({
await fetchList(); await fetchList();
}, [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 = { const value: NotificationContextValue = {
notifications, notifications,
unreadCount, unreadCount,
@@ -272,6 +292,7 @@ export function NotificationProvider({
error, error,
markRead, markRead,
markAllRead, markAllRead,
clearAll,
dismiss, dismiss,
refreshList, refreshList,
isDropdownOpen, isDropdownOpen,
+150 -17
View File
@@ -77,6 +77,11 @@ body {
color: var(--ink); color: var(--ink);
} }
html.terminal-page-open,
body.terminal-page-open {
overflow: hidden;
}
[data-theme="dark"] body { [data-theme="dark"] body {
background: radial-gradient(circle at top right, #2a2520, var(--bg)); background: radial-gradient(circle at top right, #2a2520, var(--bg));
} }
@@ -2950,42 +2955,148 @@ a.nav-item,
background: #cd3131; background: #cd3131;
} }
/* Mobile auto-hide header and tabs */ /* ============================================
.terminal-page.mobile .terminal-page-header, Mobile Terminal Overlay
.mobile-tabs-container { ============================================ */
/* 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: transition:
transform 0.3s ease, transform 0.3s ease,
opacity 0.3s ease; opacity 0.3s ease;
} }
.terminal-page.mobile .terminal-page-header.hidden, .mobile-terminal-overlay.hidden {
.mobile-tabs-container.hidden {
transform: translateY(-100%); transform: translateY(-100%);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.terminal-page.mobile .terminal-page-header.visible, .mobile-terminal-overlay.visible {
.mobile-tabs-container.visible {
transform: translateY(0); transform: translateY(0);
opacity: 1; 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;
}
/* 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 */ /* Mobile fullscreen */
@media (max-width: 767px) { @media (max-width: 767px) {
.terminal-page.fullscreen { .terminal-page.fullscreen {
padding: 0; 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 { .terminal-session-tab-name {
max-width: 80px; max-width: 80px;
} }
@@ -3597,6 +3708,7 @@ a.nav-item,
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
touch-action: none;
} }
/* xterm.js manages its own sizing */ /* xterm.js manages its own sizing */
@@ -4602,10 +4714,12 @@ a:active,
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
display: flex;
gap: 0.5rem;
} }
.notification-mark-all { .notification-mark-all {
width: 100%; flex: 1;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: transparent; background: transparent;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -4623,6 +4737,25 @@ a:active,
border-color: var(--brand); 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 */
.notification-item { .notification-item {
display: flex; display: flex;
+6 -3
View File
@@ -13,7 +13,11 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"] test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -92,7 +96,7 @@ services:
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-} AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-}
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-} AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
volumes: volumes:
- repo_data:/data/repos - /data/repos:/data/repos
- /data/instances:/data/instances - /data/instances:/data/instances
- avatar_uploads:/app/uploads - avatar_uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
@@ -116,7 +120,6 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
repo_data:
avatar_uploads: avatar_uploads:
networks: networks:
+7 -4
View File
@@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
services: services:
# PostgreSQL Database # PostgreSQL Database
@@ -14,7 +14,11 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"] test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -57,7 +61,7 @@ services:
REPO_BASE_PATH: /data/repos REPO_BASE_PATH: /data/repos
INSTANCE_BASE_PATH: /data/instances INSTANCE_BASE_PATH: /data/instances
volumes: volumes:
- repo_data:/data/repos - /data/repos:/data/repos
- /data/instances:/data/instances - /data/instances:/data/instances
ports: ports:
- "8000:8000" - "8000:8000"
@@ -91,7 +95,6 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
repo_data:
networks: networks:
backend: backend: