Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev
This commit is contained in:
@@ -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
|
||||||
@@ -5,6 +5,7 @@ Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
|||||||
Create Date: 2026-05-29 14:00:00.000000
|
Create Date: 2026-05-29 14:00:00.000000
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Sequence, Union
|
from typing import Sequence, Union
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
@@ -35,25 +36,32 @@ def _fix_code_server_compose(conn) -> None:
|
|||||||
if definition_type != "compose" or not compose_template:
|
if definition_type != "compose" or not compose_template:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add command to bind to 0.0.0.0 if not already present
|
# Fix or add command to bind to 0.0.0.0
|
||||||
if "command:" in compose_template:
|
|
||||||
# Already has a command override, skip
|
|
||||||
return
|
|
||||||
|
|
||||||
# Insert command line after the image line
|
|
||||||
lines = compose_template.split("\n")
|
lines = compose_template.split("\n")
|
||||||
new_lines = []
|
new_lines = []
|
||||||
image_line_idx = -1
|
image_line_idx = -1
|
||||||
|
command_fixed = False
|
||||||
for i, line in enumerate(lines):
|
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)
|
new_lines.append(line)
|
||||||
if "image:" in line and image_line_idx == -1:
|
if "image:" in line and image_line_idx == -1:
|
||||||
image_line_idx = i
|
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:
|
# If no command line exists, insert one after image
|
||||||
# No image line found, can't safely modify
|
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
|
return
|
||||||
|
|
||||||
updated_compose = "\n".join(new_lines)
|
updated_compose = "\n".join(new_lines)
|
||||||
@@ -67,7 +75,7 @@ def _fix_code_server_compose(conn) -> None:
|
|||||||
{"compose_template": updated_compose, "id": tool_id},
|
{"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:
|
def _fix_jupyter_compose(conn) -> None:
|
||||||
@@ -100,7 +108,9 @@ def _fix_jupyter_compose(conn) -> None:
|
|||||||
image_line_idx = i
|
image_line_idx = i
|
||||||
indent = line[: len(line) - len(line.lstrip())]
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
# Jupyter needs --ip=0.0.0.0 to bind to all interfaces
|
# 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:
|
if image_line_idx == -1:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -475,7 +475,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:
|
||||||
@@ -629,7 +629,7 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
KNOWN_BIND_FIXES = {
|
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",
|
"jupyter-notebook": "start-notebook.sh --ip=0.0.0.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,30 +648,61 @@ 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(
|
if existing_command:
|
||||||
"Injected bind address for %s: %s", tool_type_name, bind_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(
|
@router.post(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from fastapi.testclient import TestClient
|
|||||||
class TestToolTypesAPIExtended:
|
class TestToolTypesAPIExtended:
|
||||||
"""Integration tests for tool types API with new fields."""
|
"""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."""
|
"""Test creating a tool type with dockerfile definition."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -27,7 +29,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["definition_type"] == "dockerfile"
|
assert data["definition_type"] == "dockerfile"
|
||||||
assert data["dockerfile_template"] == "FROM python:3.11\nRUN pip install flask"
|
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."""
|
"""Test creating a tool type with readiness probe."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -52,7 +56,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
|
assert data["readiness_probe"]["command"] == "curl -f http://localhost:8080"
|
||||||
assert data["readiness_probe"]["timeout"] == 30
|
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."""
|
"""Test that invalid definition types are rejected."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -67,7 +73,9 @@ class TestToolTypesAPIExtended:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 422
|
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."""
|
"""Test that dockerfile type requires dockerfile_template."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -81,7 +89,9 @@ class TestToolTypesAPIExtended:
|
|||||||
)
|
)
|
||||||
assert response.status_code == 422
|
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."""
|
"""Test updating a tool type with new fields."""
|
||||||
# Create tool type first
|
# Create tool type first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
@@ -112,7 +122,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["display_name"] == "Updated Name"
|
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:
|
def test_validate_tool_type_compose(self, authenticated_client: TestClient) -> None:
|
||||||
"""Test validating compose template."""
|
"""Test validating compose template."""
|
||||||
@@ -127,7 +139,9 @@ class TestToolTypesAPIExtended:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["valid"] is True
|
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."""
|
"""Test validating invalid compose template."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types/validate",
|
"/tool-types/validate",
|
||||||
@@ -141,7 +155,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["valid"] is False
|
assert data["valid"] is False
|
||||||
assert "errors" in data
|
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."""
|
"""Test validating dockerfile template."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types/validate",
|
"/tool-types/validate",
|
||||||
@@ -154,7 +170,9 @@ class TestToolTypesAPIExtended:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["valid"] is True
|
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."""
|
"""Test that GET returns new fields."""
|
||||||
# Create tool type with all fields
|
# Create tool type with all fields
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
@@ -166,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: --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": {
|
"readiness_probe": {
|
||||||
"command": "curl -f http://localhost:8443",
|
"command": "curl -f http://localhost:8443",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
@@ -186,7 +204,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["interfaces"] == ["web", "terminal"]
|
assert data["interfaces"] == ["web", "terminal"]
|
||||||
assert "readiness_probe" in data
|
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."""
|
"""Test that creating a tool type without default_port fails validation."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -204,7 +224,9 @@ class TestToolTypesAPIExtended:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "default_port" in str(data)
|
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."""
|
"""Test that port mismatch between default_port and compose template fails."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -222,7 +244,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
_ = response.json()
|
_ = 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."""
|
"""Test creating a tool type with startup_command."""
|
||||||
response = authenticated_client.post(
|
response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
@@ -244,7 +268,9 @@ class TestToolTypesAPIExtended:
|
|||||||
assert data["startup_command"] == "cd /workspace && ls"
|
assert data["startup_command"] == "cd /workspace && ls"
|
||||||
assert data["interface_type"] == "terminal"
|
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."""
|
"""Test updating a tool type's startup_command."""
|
||||||
# Create tool type first
|
# Create tool type first
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
@@ -273,7 +299,9 @@ class TestToolTypesAPIExtended:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["startup_command"] == "source /etc/profile"
|
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."""
|
"""Test that GET returns startup_command."""
|
||||||
create_response = authenticated_client.post(
|
create_response = authenticated_client.post(
|
||||||
"/tool-types",
|
"/tool-types",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
+109
-32
@@ -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,12 @@ 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,
|
||||||
@@ -205,15 +215,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 +235,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 +257,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 +347,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 +360,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+144
-17
@@ -2950,42 +2950,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;
|
||||||
}
|
}
|
||||||
@@ -4602,10 +4708,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 +4731,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user