Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c051929f8c | |||
| 4814ec2363 | |||
| 98b9d612fa | |||
| 874873541d | |||
| ef9ac76f06 | |||
| ca9db195de | |||
| c1e16f2163 | |||
| 61d32fa00f | |||
| cddb3f8ccf | |||
| 87a938fe58 | |||
| 9157694412 | |||
| aa34314175 | |||
| c7fc386d0f | |||
| 6bd814e346 | |||
| aa25852091 | |||
| c2740cd282 | |||
| 23875bb3cc | |||
| ee1eab8408 | |||
| 2254ba7496 | |||
| 4866ad08b1 | |||
| 97ebc19313 | |||
| 946ac6f66a | |||
| 90ddee14c2 | |||
| f17f8ae8c8 | |||
| d713bfc5f9 | |||
| 27fe8c24ec | |||
| eef1e4e8c6 | |||
| a7a5905874 | |||
| 021537de56 | |||
| fdfd75790d | |||
| 3d1f8d9cf7 | |||
| eec37ab710 | |||
| 5f499ec1b0 | |||
| 2b5223097f | |||
| 1efbc289ba | |||
| 3c57c8b78b | |||
| 9c4500f9cb | |||
| 1e2c5a68cf | |||
| dc6991e6ef | |||
| cdf233378c | |||
| 23769e6ad4 | |||
| 9f8058223a | |||
| b483a34517 | |||
| a8fbca9ef5 | |||
| de8c47c81c | |||
| b11089896a | |||
| 16549709e2 | |||
| 68977b73be | |||
| 3da2bc93cb | |||
| d9632a3412 | |||
| 03d22c4d06 | |||
| 19242b4152 | |||
| ceaed9af66 | |||
| e9364fa70f |
@@ -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
|
||||||
|
|||||||
@@ -232,14 +232,6 @@ def upgrade() -> None:
|
|||||||
"writable": True,
|
"writable": True,
|
||||||
"owner": "user",
|
"owner": "user",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "ssh_keys",
|
|
||||||
"target": "/home/user/.ssh",
|
|
||||||
"source_type": "ssh_key",
|
|
||||||
"mode": "0700",
|
|
||||||
"file_mode": "0600",
|
|
||||||
"readonly": True,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "pi_state",
|
"name": "pi_state",
|
||||||
"target": "/tmp/.pi/agents",
|
"target": "/tmp/.pi/agents",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add_ssh_key_ids_to_tool_instances
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_add_ssh_key_ids_to_tool_instances
|
||||||
|
Revises: 2026_05_29_drop_ssh_key_id_from_config_profiles
|
||||||
|
Create Date: 2026-05-29 12:46:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2026_05_29_add_ssh_key_ids_to_tool_instances"
|
||||||
|
down_revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"tool_instances",
|
||||||
|
sa.Column("ssh_key_ids", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tool_instances", "ssh_key_ids")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""drop_ssh_key_id_from_config_profiles
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_drop_ssh_key_id_from_config_profiles
|
||||||
|
Revises: 069d3da4dc9b
|
||||||
|
Create Date: 2026-05-29 12:45:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2026_05_29_drop_ssh_key_id_from_config_profiles"
|
||||||
|
down_revision = "069d3da4dc9b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("config_profiles", "ssh_key_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"config_profiles",
|
||||||
|
sa.Column(
|
||||||
|
"ssh_key_id",
|
||||||
|
sa.Uuid(),
|
||||||
|
sa.ForeignKey("ssh_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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,140 @@
|
|||||||
|
"""fix web tool bind address to 0.0.0.0
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_fix_web_tool_bind_address
|
||||||
|
Revises: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
||||||
|
Create Date: 2026-05-29 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_fix_web_tool_bind_address"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_remove_ssh_keys_mount_from_manifest"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_code_server_compose(conn) -> None:
|
||||||
|
"""Update code-server compose template to bind to 0.0.0.0."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, definition_type
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'code-server'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_id, compose_template, definition_type = result
|
||||||
|
|
||||||
|
if definition_type != "compose" or not compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fix or add command to bind to 0.0.0.0
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
image_line_idx = -1
|
||||||
|
command_fixed = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
# Replace broken --bind-addr with correct --host
|
||||||
|
if "command:" in line and "--bind-addr" in line:
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
new_lines.append(f"{indent}command: --host 0.0.0.0")
|
||||||
|
command_fixed = True
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
if "image:" in line and image_line_idx == -1:
|
||||||
|
image_line_idx = i
|
||||||
|
|
||||||
|
# If no command line exists, insert one after image
|
||||||
|
if not command_fixed and image_line_idx != -1:
|
||||||
|
image_line = lines[image_line_idx]
|
||||||
|
indent = image_line[: len(image_line) - len(image_line.lstrip())]
|
||||||
|
# Insert after the image line in new_lines
|
||||||
|
insert_idx = new_lines.index(image_line) + 1
|
||||||
|
new_lines.insert(insert_idx, f"{indent}command: --host 0.0.0.0")
|
||||||
|
command_fixed = True
|
||||||
|
|
||||||
|
if not command_fixed:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_compose = "\n".join(new_lines)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated_compose, "id": tool_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Updated code-server tool type ({tool_id}) to bind to 0.0.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_jupyter_compose(conn) -> None:
|
||||||
|
"""Update jupyter-notebook compose template to bind to 0.0.0.0."""
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT id, compose_template, definition_type
|
||||||
|
FROM tool_types
|
||||||
|
WHERE name = 'jupyter-notebook'
|
||||||
|
""")
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
tool_id, compose_template, definition_type = result
|
||||||
|
|
||||||
|
if definition_type != "compose" or not compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "command:" in compose_template:
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = compose_template.split("\n")
|
||||||
|
new_lines = []
|
||||||
|
image_line_idx = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
new_lines.append(line)
|
||||||
|
if "image:" in line and image_line_idx == -1:
|
||||||
|
image_line_idx = i
|
||||||
|
indent = line[: len(line) - len(line.lstrip())]
|
||||||
|
# Jupyter needs --ip=0.0.0.0 to bind to all interfaces
|
||||||
|
new_lines.append(
|
||||||
|
f"{indent}command: start-notebook.sh --ip=0.0.0.0 --port=8888 --no-browser"
|
||||||
|
)
|
||||||
|
|
||||||
|
if image_line_idx == -1:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_compose = "\n".join(new_lines)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE tool_types
|
||||||
|
SET compose_template = :compose_template
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"compose_template": updated_compose, "id": tool_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Updated jupyter-notebook tool type ({tool_id}) to bind to 0.0.0.0:8888")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
_fix_code_server_compose(conn)
|
||||||
|
_fix_jupyter_compose(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Cannot safely downgrade without knowing the original compose_template
|
||||||
|
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
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""remove ssh_keys mount from pi-agent manifest
|
||||||
|
|
||||||
|
Revision ID: 2026_05_29_remove_ssh_keys_mount_from_manifest
|
||||||
|
Revises: 2026_05_29_add_ssh_key_ids_to_tool_instances
|
||||||
|
Create Date: 2026-05-29 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "2026_05_29_remove_ssh_keys_mount_from_manifest"
|
||||||
|
down_revision: Union[str, None] = "2026_05_29_add_ssh_key_ids_to_tool_instances"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Remove the ssh_keys mount from the pi-agent manifest."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Get the pi-agent manifest
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest_id, manifest_json = row
|
||||||
|
manifest = (
|
||||||
|
manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||||
|
)
|
||||||
|
|
||||||
|
mounts = manifest.get("mounts", [])
|
||||||
|
original_count = len(mounts)
|
||||||
|
|
||||||
|
# Remove any mount named "ssh_keys"
|
||||||
|
filtered_mounts = [m for m in mounts if m.get("name") != "ssh_keys"]
|
||||||
|
|
||||||
|
if len(filtered_mounts) < original_count:
|
||||||
|
manifest["mounts"] = filtered_mounts
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"manifest": json.dumps(manifest),
|
||||||
|
"id": manifest_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Restore the ssh_keys mount to the pi-agent manifest."""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, manifest FROM tool_definition_manifests WHERE name = 'pi-agent'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
manifest_id, manifest_json = row
|
||||||
|
manifest = (
|
||||||
|
manifest_json if isinstance(manifest_json, dict) else json.loads(manifest_json)
|
||||||
|
)
|
||||||
|
|
||||||
|
mounts = manifest.get("mounts", [])
|
||||||
|
|
||||||
|
# Check if ssh_keys mount already exists
|
||||||
|
if any(m.get("name") == "ssh_keys" for m in mounts):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add the ssh_keys mount back
|
||||||
|
mounts.append(
|
||||||
|
{
|
||||||
|
"name": "ssh_keys",
|
||||||
|
"target": "/home/user/.ssh",
|
||||||
|
"source_type": "ssh_key",
|
||||||
|
"mode": "0700",
|
||||||
|
"file_mode": "0600",
|
||||||
|
"readonly": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest["mounts"] = mounts
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE tool_definition_manifests SET manifest = :manifest WHERE id = :id"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"manifest": json.dumps(manifest),
|
||||||
|
"id": manifest_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -188,9 +188,6 @@ class ConfigProfileCreate(BaseModel):
|
|||||||
git_mounts: list[GitMountItem] = Field(
|
git_mounts: list[GitMountItem] = Field(
|
||||||
default_factory=list, description="Git repository mounts"
|
default_factory=list, description="Git repository mounts"
|
||||||
)
|
)
|
||||||
ssh_key_id: str | None = Field(
|
|
||||||
default=None, description="Optional SSH key ID to mount into containers"
|
|
||||||
)
|
|
||||||
is_default: bool = Field(
|
is_default: bool = Field(
|
||||||
default=False, description="Whether this is the default profile for its scope"
|
default=False, description="Whether this is the default profile for its scope"
|
||||||
)
|
)
|
||||||
@@ -252,9 +249,6 @@ class ConfigProfileUpdate(BaseModel):
|
|||||||
git_mounts: list[GitMountItem] | None = Field(
|
git_mounts: list[GitMountItem] | None = Field(
|
||||||
default=None, description="Git repository mounts"
|
default=None, description="Git repository mounts"
|
||||||
)
|
)
|
||||||
ssh_key_id: str | None = Field(
|
|
||||||
default=None, description="Optional SSH key ID to mount into containers"
|
|
||||||
)
|
|
||||||
is_default: bool | None = Field(
|
is_default: bool | None = Field(
|
||||||
default=None, description="Whether this is the default profile"
|
default=None, description="Whether this is the default profile"
|
||||||
)
|
)
|
||||||
@@ -382,7 +376,6 @@ def _profile_to_response(
|
|||||||
"mounts": profile.mounts or [],
|
"mounts": profile.mounts or [],
|
||||||
"git_mounts": profile.git_mounts or [],
|
"git_mounts": profile.git_mounts or [],
|
||||||
"files": profile.files or {},
|
"files": profile.files or {},
|
||||||
"ssh_key_id": str(profile.ssh_key_id) if profile.ssh_key_id else None,
|
|
||||||
"is_default": profile.is_default,
|
"is_default": profile.is_default,
|
||||||
"includes": [
|
"includes": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -75,7 +74,7 @@ from src.services.manifest_compiler import (
|
|||||||
merge_with_config,
|
merge_with_config,
|
||||||
resolve_base,
|
resolve_base,
|
||||||
)
|
)
|
||||||
from src.services.permission_fixer import apply_mount_permissions
|
from src.services.permission_fixer import apply_mount_permissions, apply_ssh_permissions
|
||||||
from src.services.readiness_probe import execute_probe
|
from src.services.readiness_probe import execute_probe
|
||||||
from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
|
from src.services.ssh_keys import cleanup_ssh_key_files, prepare_ssh_key_files
|
||||||
|
|
||||||
@@ -435,6 +434,9 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
config_profile_id: str | None = Field(
|
config_profile_id: str | None = Field(
|
||||||
default=None, description="Optional config profile ID for launch"
|
default=None, description="Optional config profile ID for launch"
|
||||||
)
|
)
|
||||||
|
ssh_key_ids: list[str] = Field(
|
||||||
|
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StartInstanceRequest(BaseModel):
|
class StartInstanceRequest(BaseModel):
|
||||||
@@ -445,6 +447,9 @@ class StartInstanceRequest(BaseModel):
|
|||||||
config_profile_id: str | None = Field(
|
config_profile_id: str | None = Field(
|
||||||
default=None, description="Config profile ID to apply, or null for none"
|
default=None, description="Config profile ID to apply, or null for none"
|
||||||
)
|
)
|
||||||
|
ssh_key_ids: list[str] = Field(
|
||||||
|
default_factory=list, description="SSH key IDs to mount into container ~/.ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _validate_config_profile(
|
async def _validate_config_profile(
|
||||||
@@ -469,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:
|
||||||
@@ -612,6 +617,137 @@ 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_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.
|
||||||
|
|
||||||
|
Many web tools (code-server, jupyter) bind to localhost by default,
|
||||||
|
making them inaccessible from the Docker network. This function detects
|
||||||
|
known tool images and injects the correct --bind-addr or --ip flag.
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if default_port <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
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)
|
||||||
|
if not bind_command:
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
for service_config in compose_data["services"].values():
|
||||||
|
image = service_config.get("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
|
||||||
|
|
||||||
|
# Check if the image matches a known tool
|
||||||
|
is_code_server = tool_type_name == "code-server" and (
|
||||||
|
"code-server" in image or "coder" in image
|
||||||
|
)
|
||||||
|
is_jupyter = tool_type_name == "jupyter-notebook" and (
|
||||||
|
"jupyter" in image or "notebook" in image
|
||||||
|
)
|
||||||
|
if not is_code_server and not is_jupyter:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing_command = service_config.get("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(
|
||||||
"/{project_id}/repositories/{repo_id}/instances",
|
"/{project_id}/repositories/{repo_id}/instances",
|
||||||
summary="Create tool instance",
|
summary="Create tool instance",
|
||||||
@@ -848,7 +984,7 @@ services:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Determine home directory for path expansion
|
# Determine home directory for path expansion
|
||||||
home_dir = get_manifest_home_dir(manifest)
|
_home_dir = get_manifest_home_dir(manifest)
|
||||||
|
|
||||||
image_tag = compute_image_tag(tool_type.name, manifest)
|
image_tag = compute_image_tag(tool_type.name, manifest)
|
||||||
|
|
||||||
@@ -964,6 +1100,7 @@ services:
|
|||||||
if data.new_branch
|
if data.new_branch
|
||||||
else (data.branch if data.clone_mode == "clone" else None),
|
else (data.branch if data.clone_mode == "clone" else None),
|
||||||
selected_config_profile_id=selected_profile_id,
|
selected_config_profile_id=selected_profile_id,
|
||||||
|
ssh_key_ids=data.ssh_key_ids or None,
|
||||||
)
|
)
|
||||||
session.add(instance)
|
session.add(instance)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -1054,6 +1191,7 @@ async def list_instances(
|
|||||||
"port": i.port,
|
"port": i.port,
|
||||||
"clone_mode": i.clone_mode,
|
"clone_mode": i.clone_mode,
|
||||||
"branch": i.branch,
|
"branch": i.branch,
|
||||||
|
"ssh_key_ids": i.ssh_key_ids or [],
|
||||||
"created_at": i.created_at.isoformat(),
|
"created_at": i.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1300,6 +1438,11 @@ async def start_instance(
|
|||||||
instance.selected_config_profile_id = selected_profile_id
|
instance.selected_config_profile_id = selected_profile_id
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
# Store SSH key selection if provided
|
||||||
|
if data and data.ssh_key_ids is not None:
|
||||||
|
instance.ssh_key_ids = data.ssh_key_ids or None
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
if not instance.compose_path or not os.path.exists(instance.compose_path):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
status_code=status.HTTP_400_BAD_REQUEST, detail="compose file not found"
|
||||||
@@ -1317,15 +1460,38 @@ async def start_instance(
|
|||||||
working_directory = None
|
working_directory = None
|
||||||
extra_volumes = []
|
extra_volumes = []
|
||||||
|
|
||||||
# Fetch tool type early to determine home directory
|
# Fetch tool type early to determine home directory and container user
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
home_dir = "/root"
|
home_dir = "/root"
|
||||||
|
container_uid = 0
|
||||||
|
container_gid = 0
|
||||||
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
|
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
|
||||||
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
|
||||||
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
|
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
|
||||||
if manifest_def:
|
if manifest_def:
|
||||||
home_dir = get_manifest_home_dir(dict(manifest_def.manifest))
|
manifest = dict(manifest_def.manifest)
|
||||||
|
# Merge with base definition if referenced (user config is often in base)
|
||||||
|
if manifest_def.base_definition_id:
|
||||||
|
base_def = await session.get(
|
||||||
|
ToolDefinitionManifest, manifest_def.base_definition_id
|
||||||
|
)
|
||||||
|
if base_def:
|
||||||
|
manifest = resolve_base(
|
||||||
|
deep_merge(dict(base_def.manifest), manifest)
|
||||||
|
)
|
||||||
|
home_dir = get_manifest_home_dir(manifest)
|
||||||
|
user_cfg = manifest.get("user")
|
||||||
|
if user_cfg:
|
||||||
|
container_uid = user_cfg.get("uid", 0)
|
||||||
|
container_gid = user_cfg.get("gid", 0)
|
||||||
|
logger.debug(
|
||||||
|
"Manifest user resolved for instance %s: uid=%s, gid=%s, home=%s",
|
||||||
|
instance.id,
|
||||||
|
container_uid,
|
||||||
|
container_gid,
|
||||||
|
home_dir,
|
||||||
|
)
|
||||||
|
|
||||||
# Apply selected config profile if any
|
# Apply selected config profile if any
|
||||||
instance_dir = os.path.dirname(instance.compose_path)
|
instance_dir = os.path.dirname(instance.compose_path)
|
||||||
@@ -1355,40 +1521,6 @@ async def start_instance(
|
|||||||
working_directory = profile_hints["working_directory"]
|
working_directory = profile_hints["working_directory"]
|
||||||
if profile_hints.get("port_override"):
|
if profile_hints.get("port_override"):
|
||||||
port_override = profile_hints["port_override"]
|
port_override = profile_hints["port_override"]
|
||||||
# Mount SSH key from config profile into container home dir
|
|
||||||
if resolved.ssh_key_id is not None:
|
|
||||||
ssh_key = await session.get(SSHKey, resolved.ssh_key_id)
|
|
||||||
if ssh_key:
|
|
||||||
try:
|
|
||||||
ssh_dir = prepare_ssh_key_files(
|
|
||||||
instance_dir, ssh_key, subdir="mounts/ssh/.ssh"
|
|
||||||
)
|
|
||||||
ssh_target = os.path.join(home_dir, ".ssh")
|
|
||||||
extra_volumes.append(
|
|
||||||
{
|
|
||||||
"source": ssh_dir,
|
|
||||||
"target": ssh_target,
|
|
||||||
"type": "ro",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
"Mounted SSH key %s for instance %s to %s",
|
|
||||||
ssh_key.name,
|
|
||||||
instance.id,
|
|
||||||
ssh_target,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(
|
|
||||||
"Failed to prepare SSH key for instance %s: %s",
|
|
||||||
instance.id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"SSH key %s not found for config profile %s",
|
|
||||||
resolved.ssh_key_id,
|
|
||||||
resolved.profile_name,
|
|
||||||
)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
"Applied config profile %s to instance %s (env=%d, files=%d, mounts=%d, git_mounts=%d)",
|
||||||
resolved.profile_name,
|
resolved.profile_name,
|
||||||
@@ -1422,6 +1554,47 @@ async def start_instance(
|
|||||||
"Wrote %d config files for instance %s", len(config_files), instance.id
|
"Wrote %d config files for instance %s", len(config_files), instance.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Mount selected SSH keys into container home dir
|
||||||
|
if instance.ssh_key_ids:
|
||||||
|
for key_id in instance.ssh_key_ids:
|
||||||
|
ssh_key = await session.get(SSHKey, uuid.UUID(key_id))
|
||||||
|
if ssh_key and ssh_key.user_id == user_id:
|
||||||
|
try:
|
||||||
|
ssh_dir = prepare_ssh_key_files(
|
||||||
|
instance_dir,
|
||||||
|
ssh_key,
|
||||||
|
subdir=f"mounts/ssh/{key_id}/.ssh",
|
||||||
|
uid=container_uid,
|
||||||
|
gid=container_gid,
|
||||||
|
)
|
||||||
|
ssh_target = os.path.join(home_dir, ".ssh")
|
||||||
|
extra_volumes.append(
|
||||||
|
{
|
||||||
|
"source": ssh_dir,
|
||||||
|
"target": ssh_target,
|
||||||
|
"type": "bind",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Mounted SSH key %s for instance %s to %s",
|
||||||
|
ssh_key.name,
|
||||||
|
instance.id,
|
||||||
|
ssh_target,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to prepare SSH key %s for instance %s: %s",
|
||||||
|
key_id,
|
||||||
|
instance.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"SSH key %s not found or not authorized for user %s",
|
||||||
|
key_id,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
# ── MANIFEST-BASED FLOW ──────────────────────────────────────
|
# ── MANIFEST-BASED FLOW ──────────────────────────────────────
|
||||||
resolved_manifest = None
|
resolved_manifest = None
|
||||||
|
|
||||||
@@ -1472,12 +1645,14 @@ async def start_instance(
|
|||||||
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
ssh_key = await session.get(SSHKey, repo.ssh_key_id)
|
||||||
if ssh_key:
|
if ssh_key:
|
||||||
try:
|
try:
|
||||||
ssh_dir = prepare_ssh_key_files(instance_dir, ssh_key)
|
ssh_dir = prepare_ssh_key_files(
|
||||||
|
instance_dir, ssh_key, uid=0, gid=0
|
||||||
|
)
|
||||||
extra_volumes.append(
|
extra_volumes.append(
|
||||||
{
|
{
|
||||||
"source": ssh_dir,
|
"source": ssh_dir,
|
||||||
"target": "/root/.ssh",
|
"target": "/root/.ssh",
|
||||||
"type": "ro",
|
"type": "bind",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -1505,6 +1680,15 @@ async def start_instance(
|
|||||||
# Sanitize compose file to remove invalid port mappings from old instances
|
# Sanitize compose file to remove invalid port mappings from old instances
|
||||||
_sanitize_compose_file(instance.compose_path)
|
_sanitize_compose_file(instance.compose_path)
|
||||||
|
|
||||||
|
# Auto-fix bind address for known web tools that default to localhost
|
||||||
|
if tool_type and tool_type.interface_type == "web":
|
||||||
|
_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(
|
||||||
"Running docker compose up for instance %s (compose_path=%s)",
|
"Running docker compose up for instance %s (compose_path=%s)",
|
||||||
@@ -1531,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:
|
||||||
@@ -1635,6 +1818,34 @@ async def start_instance(
|
|||||||
result["error"],
|
result["error"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fix SSH key ownership/permissions inside the container
|
||||||
|
if instance.ssh_key_ids and instance.container_id:
|
||||||
|
container_user = (
|
||||||
|
"root"
|
||||||
|
if home_dir == "/root"
|
||||||
|
else home_dir[6:]
|
||||||
|
if home_dir.startswith("/home/")
|
||||||
|
else "root"
|
||||||
|
)
|
||||||
|
ssh_target = os.path.join(home_dir, ".ssh")
|
||||||
|
logger.debug(
|
||||||
|
"Applying SSH permissions for user %s on %s in instance %s",
|
||||||
|
container_user,
|
||||||
|
ssh_target,
|
||||||
|
instance.id,
|
||||||
|
)
|
||||||
|
ssh_perm_result = apply_ssh_permissions(
|
||||||
|
instance.container_id,
|
||||||
|
ssh_target,
|
||||||
|
container_user,
|
||||||
|
)
|
||||||
|
if not ssh_perm_result["success"]:
|
||||||
|
logger.warning(
|
||||||
|
"SSH permission fix failed for instance %s: %s",
|
||||||
|
instance.id,
|
||||||
|
ssh_perm_result["error"],
|
||||||
|
)
|
||||||
|
|
||||||
# Execute readiness probe if configured
|
# Execute readiness probe if configured
|
||||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||||
if tool_type and instance.container_id:
|
if tool_type and instance.container_id:
|
||||||
@@ -1947,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"
|
||||||
)
|
)
|
||||||
@@ -1976,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"]
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
|
||||||
from src.models.tool_type import ToolType
|
from src.models.tool_type import ToolType
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
|
||||||
@@ -43,15 +42,11 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
git_mounts: Mapped[list] = mapped_column(
|
git_mounts: Mapped[list] = mapped_column(
|
||||||
JSON, default=list, nullable=False
|
JSON, default=list, nullable=False
|
||||||
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
) # [{"remote_url": "https://github.com/user/repo.git", "source_path": ".", "target_path": "/path", "branch": "main"}, ...]
|
||||||
ssh_key_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(), ForeignKey("ssh_keys.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship()
|
user: Mapped["User"] = relationship()
|
||||||
project: Mapped["Project | None"] = relationship()
|
project: Mapped["Project | None"] = relationship()
|
||||||
tool_type: Mapped["ToolType | None"] = relationship()
|
tool_type: Mapped["ToolType | None"] = relationship()
|
||||||
ssh_key: Mapped["SSHKey | None"] = relationship()
|
|
||||||
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
includes: Mapped[list["ConfigProfileInclude"]] = relationship(
|
||||||
"ConfigProfileInclude",
|
"ConfigProfileInclude",
|
||||||
foreign_keys="ConfigProfileInclude.profile_id",
|
foreign_keys="ConfigProfileInclude.profile_id",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
selected_config_profile_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
ssh_key_ids: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
tool_type: Mapped["ToolType"] = relationship()
|
tool_type: Mapped["ToolType"] = relationship()
|
||||||
repository: Mapped["GitRepository"] = relationship()
|
repository: Mapped["GitRepository"] = relationship()
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class ResolvedProfile:
|
|||||||
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
mounts: dict[str, ResolvedMount] = field(default_factory=dict)
|
||||||
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
git_mounts: list[dict[str, Any]] = field(default_factory=list)
|
||||||
files: dict[str, str] = field(default_factory=dict)
|
files: dict[str, str] = field(default_factory=dict)
|
||||||
ssh_key_id: uuid.UUID | None = None
|
|
||||||
env_overrides: dict[str, str] = field(default_factory=dict)
|
env_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
hint_overrides: dict[str, str] = field(default_factory=dict)
|
hint_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
file_overrides: dict[str, str] = field(default_factory=dict)
|
file_overrides: dict[str, str] = field(default_factory=dict)
|
||||||
@@ -319,9 +318,6 @@ async def _resolve_profile_recursive(
|
|||||||
result.git_mounts = _merge_git_mounts(
|
result.git_mounts = _merge_git_mounts(
|
||||||
result.git_mounts, included.git_mounts, included.profile_name
|
result.git_mounts, included.git_mounts, included.profile_name
|
||||||
)
|
)
|
||||||
# Later included profile's SSH key wins
|
|
||||||
if included.ssh_key_id is not None:
|
|
||||||
result.ssh_key_id = included.ssh_key_id
|
|
||||||
|
|
||||||
# Apply the profile's own settings (selected profile overrides includes)
|
# Apply the profile's own settings (selected profile overrides includes)
|
||||||
result.env_vars = _merge_env_vars(
|
result.env_vars = _merge_env_vars(
|
||||||
@@ -353,9 +349,6 @@ async def _resolve_profile_recursive(
|
|||||||
profile.git_mounts or [],
|
profile.git_mounts or [],
|
||||||
profile.name,
|
profile.name,
|
||||||
)
|
)
|
||||||
# Own SSH key overrides any inherited one
|
|
||||||
if profile.ssh_key_id is not None:
|
|
||||||
result.ssh_key_id = profile.ssh_key_id
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -571,5 +564,4 @@ def resolved_profile_to_dict(resolved: ResolvedProfile) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
"git_mounts": resolved.git_mounts,
|
"git_mounts": resolved.git_mounts,
|
||||||
"included_profiles": resolved.included_profiles,
|
"included_profiles": resolved.included_profiles,
|
||||||
"ssh_key_id": str(resolved.ssh_key_id) if resolved.ssh_key_id else None,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"):
|
||||||
@@ -384,6 +384,85 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
|||||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_app_binding(container_name: str, port: int) -> dict[str, str | bool]:
|
||||||
|
"""Diagnose whether the app is bound to 127.0.0.1 or 0.0.0.0.
|
||||||
|
|
||||||
|
Checks from both inside the container (localhost) and outside
|
||||||
|
(via Docker network) to detect binding issues.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'internal_ok', 'external_ok', 'internal_status',
|
||||||
|
'external_status', and 'diagnosis'.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"internal_ok": False,
|
||||||
|
"external_ok": False,
|
||||||
|
"internal_status": None,
|
||||||
|
"external_status": None,
|
||||||
|
"diagnosis": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check from inside the container (loopback)
|
||||||
|
internal = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
container_name,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{port}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if internal.returncode == 0:
|
||||||
|
try:
|
||||||
|
result["internal_status"] = int(internal.stdout.strip())
|
||||||
|
result["internal_ok"] = result["internal_status"] > 0
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check from outside the container (Docker network)
|
||||||
|
external = subprocess.run(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
f"http://{container_name}:{port}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if external.returncode == 0:
|
||||||
|
try:
|
||||||
|
result["external_status"] = int(external.stdout.strip())
|
||||||
|
result["external_ok"] = result["external_status"] > 0
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Diagnose binding issue
|
||||||
|
if result["internal_ok"] and not result["external_ok"]:
|
||||||
|
result["diagnosis"] = (
|
||||||
|
f"App appears to be bound to 127.0.0.1:{port} inside the container. "
|
||||||
|
f"It must bind to 0.0.0.0:{port} to be accessible from the tunnel."
|
||||||
|
)
|
||||||
|
elif result["internal_ok"] and result["external_ok"]:
|
||||||
|
result["diagnosis"] = "App is accessible on both interfaces."
|
||||||
|
elif not result["internal_ok"] and not result["external_ok"]:
|
||||||
|
result["diagnosis"] = f"App is not responding on port {port} at all."
|
||||||
|
else:
|
||||||
|
result["diagnosis"] = "Unexpected binding state."
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def start_cloudflared_tunnel(
|
def start_cloudflared_tunnel(
|
||||||
container_name: str, port: int, timeout: int = 30
|
container_name: str, port: int, timeout: int = 30
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -405,9 +484,11 @@ def start_cloudflared_tunnel(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# First verify the container is accessible
|
# First verify the container is accessible from the Docker network
|
||||||
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
||||||
for attempt in range(10):
|
accessible = False
|
||||||
|
last_status = None
|
||||||
|
for attempt in range(30): # 30 attempts × 1s = 30s max wait for app startup
|
||||||
check = subprocess.run(
|
check = subprocess.run(
|
||||||
[
|
[
|
||||||
"curl",
|
"curl",
|
||||||
@@ -416,21 +497,59 @@ def start_cloudflared_tunnel(
|
|||||||
"/dev/null",
|
"/dev/null",
|
||||||
"-w",
|
"-w",
|
||||||
"%{http_code}",
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
"3",
|
||||||
f"http://{container_name}:{port}",
|
f"http://{container_name}:{port}",
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
|
status_str = check.stdout.strip()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip()
|
"Connectivity check %d/%d: http_code=%s (rc=%d)",
|
||||||
|
attempt + 1,
|
||||||
|
30,
|
||||||
|
status_str,
|
||||||
|
check.returncode,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
last_status = int(status_str)
|
||||||
|
# Accept 2xx, 3xx, 401, 403 as "app is listening"
|
||||||
|
if last_status in (401, 403) or 200 <= last_status < 400:
|
||||||
|
accessible = True
|
||||||
|
logger.info(
|
||||||
|
"App on %s:%d is ready (HTTP %d)",
|
||||||
|
container_name,
|
||||||
|
port,
|
||||||
|
last_status,
|
||||||
)
|
)
|
||||||
if check.returncode == 0:
|
|
||||||
break
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if check.returncode != 0:
|
||||||
|
logger.debug(
|
||||||
|
"curl failed: stderr=%s", check.stderr.strip() if check.stderr else ""
|
||||||
|
)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
|
||||||
|
if not accessible:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Container %s:%d not responding to curl checks", container_name, port
|
"Container %s:%d not responding after 30s (last status: %s). "
|
||||||
|
"Running binding diagnostics...",
|
||||||
|
container_name,
|
||||||
|
port,
|
||||||
|
last_status,
|
||||||
|
)
|
||||||
|
diagnosis = _check_app_binding(container_name, port)
|
||||||
|
logger.warning(
|
||||||
|
"Binding diagnosis: internal=%s (HTTP %s), external=%s (HTTP %s). %s",
|
||||||
|
diagnosis["internal_ok"],
|
||||||
|
diagnosis["internal_status"],
|
||||||
|
diagnosis["external_ok"],
|
||||||
|
diagnosis["external_status"],
|
||||||
|
diagnosis["diagnosis"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run cloudflared in background, capture output
|
# Run cloudflared in background, capture output
|
||||||
|
|||||||
@@ -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:
|
||||||
severity = "info"
|
# Running/recovered — do not notify
|
||||||
title = "Container recovered"
|
return
|
||||||
|
|
||||||
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,7 +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)
|
||||||
severity = "error" if event_type == "instance.error" else "info"
|
# Only send warnings, errors, and "container is ready" notifications.
|
||||||
|
effective_status = status or instance.status
|
||||||
|
if not _should_notify(event_type, effective_status):
|
||||||
|
return
|
||||||
|
|
||||||
|
severity = "error" if event_type == "instance.error" else "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,
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ def apply_mount_permissions(
|
|||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Skip read-only mounts — their permissions cannot be changed
|
||||||
|
# post-start because the bind mount is locked.
|
||||||
|
if mount.get("readonly", False):
|
||||||
|
logger.debug(
|
||||||
|
"Skipping permission fix for read-only mount %s (target=%s)",
|
||||||
|
name,
|
||||||
|
target,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip if no permission policy defined
|
# Skip if no permission policy defined
|
||||||
if not owner and not mode and not file_mode:
|
if not owner and not mode and not file_mode:
|
||||||
results.append(result)
|
results.append(result)
|
||||||
@@ -104,6 +115,141 @@ def apply_mount_permissions(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_and_log(
|
||||||
|
container_id: str,
|
||||||
|
command: list[str],
|
||||||
|
timeout: int,
|
||||||
|
description: str,
|
||||||
|
) -> str:
|
||||||
|
"""Run a docker exec command and log stdout/stderr for debugging."""
|
||||||
|
cmd = ["docker", "exec", "--user", "root", container_id] + command
|
||||||
|
logger.debug("[SSH-fix] %s: %s", description, " ".join(cmd))
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command timed out after {timeout}s: {' '.join(command)}"
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise PermissionFixError(f"Docker command not found: {' '.join(command)}")
|
||||||
|
|
||||||
|
stdout = result.stdout.strip()
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
if stdout:
|
||||||
|
logger.debug("[SSH-fix] %s stdout: %s", description, stdout)
|
||||||
|
if stderr:
|
||||||
|
logger.debug("[SSH-fix] %s stderr: %s", description, stderr)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise PermissionFixError(
|
||||||
|
f"Command failed (rc={result.returncode}): {stderr or '(no stderr)'}"
|
||||||
|
)
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ssh_permissions(
|
||||||
|
container_id: str,
|
||||||
|
ssh_target: str,
|
||||||
|
container_user: str,
|
||||||
|
timeout: int = 10,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fix SSH directory ownership and permissions in a running container.
|
||||||
|
|
||||||
|
Runs chown and chmod on the ~/.ssh directory so the container user
|
||||||
|
can use the keys (SSH requires the private key to be owned by the
|
||||||
|
user with mode 600).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
container_id: Docker container ID or name.
|
||||||
|
ssh_target: Absolute path to the .ssh directory inside the container.
|
||||||
|
container_user: The container user that should own the keys.
|
||||||
|
timeout: Max seconds per docker exec command.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result dict with keys: success, error.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {"success": True, "error": None}
|
||||||
|
try:
|
||||||
|
# 1. Ensure directory is owned by the container user
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["chown", "-R", f"{container_user}:{container_user}", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"chown",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Set directory permissions
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["chmod", "700", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"chmod-dir",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Set private key permissions (id_ed25519, id_rsa, etc.)
|
||||||
|
_exec_and_log(
|
||||||
|
container_id,
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"find {ssh_target} -name 'id_*' -type f -exec chmod 600 {{}} +",
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
"chmod-keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Verify final state
|
||||||
|
ls_output = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["ls", "-la", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"verify-ls",
|
||||||
|
)
|
||||||
|
stat_output = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
["stat", "-c", "%U:%G %a %n", ssh_target],
|
||||||
|
timeout,
|
||||||
|
"verify-stat-dir",
|
||||||
|
)
|
||||||
|
key_stat = _exec_and_log(
|
||||||
|
container_id,
|
||||||
|
[
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
f"stat -c '%U:%G %a %n' {ssh_target}/id_* 2>/dev/null || echo 'no id_* files found'",
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
"verify-stat-keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SSH permissions fixed for container %s (user=%s, target=%s). "
|
||||||
|
"ls:\n%s\nstat-dir: %s\nstat-keys: %s",
|
||||||
|
container_id,
|
||||||
|
container_user,
|
||||||
|
ssh_target,
|
||||||
|
ls_output,
|
||||||
|
stat_output,
|
||||||
|
key_stat,
|
||||||
|
)
|
||||||
|
except PermissionFixError as exc:
|
||||||
|
result["success"] = False
|
||||||
|
result["error"] = str(exc)
|
||||||
|
logger.warning(
|
||||||
|
"SSH permission fix failed for container %s (target=%s): %s",
|
||||||
|
container_id,
|
||||||
|
ssh_target,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class PermissionFixError(Exception):
|
class PermissionFixError(Exception):
|
||||||
"""Raised when a permission fix command fails."""
|
"""Raised when a permission fix command fails."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""SSH key service utilities for preparing keys for container use."""
|
"""SSH key service utilities for preparing keys for container use."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ from cryptography.fernet import Fernet
|
|||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet() -> Fernet:
|
def _get_fernet() -> Fernet:
|
||||||
"""Generate a valid Fernet key from the session secret."""
|
"""Generate a valid Fernet key from the session secret."""
|
||||||
@@ -19,13 +22,21 @@ def _get_fernet() -> Fernet:
|
|||||||
return Fernet(key)
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
def prepare_ssh_key_files(instance_dir: str, ssh_key, subdir: str = ".ssh") -> str:
|
def prepare_ssh_key_files(
|
||||||
|
instance_dir: str,
|
||||||
|
ssh_key,
|
||||||
|
subdir: str = ".ssh",
|
||||||
|
uid: int | None = None,
|
||||||
|
gid: int | None = None,
|
||||||
|
) -> str:
|
||||||
"""Decrypt and write SSH key files to instance directory for container mounting.
|
"""Decrypt and write SSH key files to instance directory for container mounting.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instance_dir: Path to instance directory
|
instance_dir: Path to instance directory
|
||||||
ssh_key: SSHKey model instance with encrypted private key
|
ssh_key: SSHKey model instance with encrypted private key
|
||||||
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
subdir: Subdirectory within instance_dir to write to (default: ".ssh")
|
||||||
|
uid: Optional UID to own the files (for bind-mount into non-root container)
|
||||||
|
gid: Optional GID to own the files
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the .ssh directory
|
Path to the .ssh directory
|
||||||
@@ -58,6 +69,30 @@ def prepare_ssh_key_files(instance_dir: str, ssh_key, subdir: str = ".ssh") -> s
|
|||||||
config_path.write_text(config_content)
|
config_path.write_text(config_content)
|
||||||
os.chmod(config_path, 0o644)
|
os.chmod(config_path, 0o644)
|
||||||
|
|
||||||
|
# Set ownership to target container user if requested
|
||||||
|
if uid is not None or gid is not None:
|
||||||
|
effective_uid = uid if uid is not None else -1
|
||||||
|
effective_gid = gid if gid is not None else -1
|
||||||
|
try:
|
||||||
|
os.chown(ssh_dir, effective_uid, effective_gid)
|
||||||
|
os.chown(private_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(public_key_path, effective_uid, effective_gid)
|
||||||
|
os.chown(config_path, effective_uid, effective_gid)
|
||||||
|
logger.debug(
|
||||||
|
"Set SSH key ownership to uid=%s gid=%s for %s",
|
||||||
|
effective_uid,
|
||||||
|
effective_gid,
|
||||||
|
ssh_dir,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot chown SSH keys to uid=%s gid=%s (running as uid=%s): %s",
|
||||||
|
effective_uid,
|
||||||
|
effective_gid,
|
||||||
|
os.getuid(),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
return str(ssh_dir)
|
return str(ssh_dir)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ async def test_instance(db_session: AsyncSession) -> ToolInstance:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_lifecycle_event_creates_notification(
|
async def test_lifecycle_started_intermediate_skips_notification(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
event_bus: InstanceEventBus,
|
event_bus: InstanceEventBus,
|
||||||
test_instance: ToolInstance,
|
test_instance: ToolInstance,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Triggering a lifecycle event creates a notification for the instance owner."""
|
"""Intermediate 'starting' state does NOT create a notification."""
|
||||||
received: list[InstanceEventPayload] = []
|
received: list[InstanceEventPayload] = []
|
||||||
|
|
||||||
def subscriber(payload: InstanceEventPayload) -> None:
|
def subscriber(payload: InstanceEventPayload) -> None:
|
||||||
@@ -109,13 +109,39 @@ async def test_lifecycle_event_creates_notification(
|
|||||||
instance=test_instance,
|
instance=test_instance,
|
||||||
event_type="instance.started",
|
event_type="instance.started",
|
||||||
status="starting",
|
status="starting",
|
||||||
message="Container started",
|
message="Container starting...",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Event still published
|
# Event still published
|
||||||
assert len(received) == 1
|
assert len(received) == 1
|
||||||
|
|
||||||
# Notification created
|
# No notification created for intermediate state
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
||||||
|
)
|
||||||
|
notifications = list(result.scalars().all())
|
||||||
|
assert len(notifications) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_lifecycle_running_creates_notification(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
event_bus: InstanceEventBus,
|
||||||
|
test_instance: ToolInstance,
|
||||||
|
) -> None:
|
||||||
|
"""Successful terminal state (running) creates a notification."""
|
||||||
|
from src.services.lifecycle_hooks import publish_lifecycle_event
|
||||||
|
|
||||||
|
await publish_lifecycle_event(
|
||||||
|
event_bus=event_bus,
|
||||||
|
session=db_session,
|
||||||
|
instance=test_instance,
|
||||||
|
event_type="instance.health_changed",
|
||||||
|
status="running",
|
||||||
|
message="Container running",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
select(Notification).where(Notification.user_id == test_instance.owner_id)
|
||||||
)
|
)
|
||||||
@@ -123,11 +149,10 @@ async def test_lifecycle_event_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 == "Container started"
|
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
|
||||||
assert n.message == "Container started"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -290,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(
|
||||||
|
|||||||
@@ -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 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,
|
||||||
@@ -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
|
||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
from src.services.permission_fixer import (
|
from src.services.permission_fixer import (
|
||||||
PermissionFixError,
|
PermissionFixError,
|
||||||
apply_mount_permissions,
|
apply_mount_permissions,
|
||||||
|
apply_ssh_permissions,
|
||||||
check_root_user_available,
|
check_root_user_available,
|
||||||
_run_in_container,
|
_run_in_container,
|
||||||
)
|
)
|
||||||
@@ -63,6 +64,24 @@ class TestApplyMountPermissions:
|
|||||||
"find /home/user/.ssh -type f -exec chmod 0600" in file_mode_call[0][1][2]
|
"find /home/user/.ssh -type f -exec chmod 0600" in file_mode_call[0][1][2]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
|
def test_skips_readonly_mount(self, mock_run) -> None:
|
||||||
|
mounts = [
|
||||||
|
{
|
||||||
|
"name": "ssh_keys",
|
||||||
|
"target": "/home/user/.ssh",
|
||||||
|
"readonly": True,
|
||||||
|
"mode": "0700",
|
||||||
|
"file_mode": "0600",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
results = apply_mount_permissions("abc123", mounts)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["mount_name"] == "ssh_keys"
|
||||||
|
assert results[0]["success"] is True
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
@patch("src.services.permission_fixer._run_in_container")
|
@patch("src.services.permission_fixer._run_in_container")
|
||||||
def test_skips_mount_with_no_policy(self, mock_run) -> None:
|
def test_skips_mount_with_no_policy(self, mock_run) -> None:
|
||||||
mounts = [
|
mounts = [
|
||||||
@@ -132,6 +151,79 @@ class TestRunInContainer:
|
|||||||
_run_in_container("abc123", ["chown", "x"], 10)
|
_run_in_container("abc123", ["chown", "x"], 10)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplySshPermissions:
|
||||||
|
"""Tests for apply_ssh_permissions."""
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_applies_chown_chmod_and_file_mode(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
result = apply_ssh_permissions("abc123", "/home/user/.ssh", "user")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
# 3 fix commands + 3 verification commands
|
||||||
|
assert mock_run.call_count == 6
|
||||||
|
chown_cmd = mock_run.call_args_list[0][0][0]
|
||||||
|
chmod_cmd = mock_run.call_args_list[1][0][0]
|
||||||
|
file_mode_cmd = mock_run.call_args_list[2][0][0]
|
||||||
|
|
||||||
|
assert chown_cmd == [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"--user",
|
||||||
|
"root",
|
||||||
|
"abc123",
|
||||||
|
"chown",
|
||||||
|
"-R",
|
||||||
|
"user:user",
|
||||||
|
"/home/user/.ssh",
|
||||||
|
]
|
||||||
|
assert chmod_cmd == [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"--user",
|
||||||
|
"root",
|
||||||
|
"abc123",
|
||||||
|
"chmod",
|
||||||
|
"700",
|
||||||
|
"/home/user/.ssh",
|
||||||
|
]
|
||||||
|
assert file_mode_cmd[0] == "docker"
|
||||||
|
assert (
|
||||||
|
"find /home/user/.ssh -name 'id_*' -type f -exec chmod 600"
|
||||||
|
in file_mode_cmd[-1]
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_uses_root_user(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
result = apply_ssh_permissions("abc123", "/root/.ssh", "root")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
chown_cmd = mock_run.call_args_list[0][0][0]
|
||||||
|
assert chown_cmd == [
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"--user",
|
||||||
|
"root",
|
||||||
|
"abc123",
|
||||||
|
"chown",
|
||||||
|
"-R",
|
||||||
|
"root:root",
|
||||||
|
"/root/.ssh",
|
||||||
|
]
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
def test_reports_failure(self, mock_run) -> None:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1, stdout="", stderr="chown failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = apply_ssh_permissions("abc123", "/home/user/.ssh", "user")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "chown failed" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
class TestCheckRootUserAvailable:
|
class TestCheckRootUserAvailable:
|
||||||
"""Tests for check_root_user_available."""
|
"""Tests for check_root_user_available."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Unit tests for SSH key preparation."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.services.ssh_keys import prepare_ssh_key_files
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrepareSshKeyFiles:
|
||||||
|
"""Tests for prepare_ssh_key_files."""
|
||||||
|
|
||||||
|
@patch("src.services.ssh_keys._get_fernet")
|
||||||
|
def test_creates_files_with_default_permissions(
|
||||||
|
self, mock_fernet, tmp_path
|
||||||
|
) -> None:
|
||||||
|
mock_fernet.return_value.decrypt.return_value = b"private-key-content"
|
||||||
|
ssh_key = MagicMock()
|
||||||
|
ssh_key.private_key_encrypted = "enc"
|
||||||
|
ssh_key.public_key = "ssh-ed25519 AAA test@test"
|
||||||
|
|
||||||
|
ssh_dir = prepare_ssh_key_files(str(tmp_path), ssh_key)
|
||||||
|
|
||||||
|
assert Path(ssh_dir).exists()
|
||||||
|
assert (Path(ssh_dir) / "id_ed25519").exists()
|
||||||
|
assert (Path(ssh_dir) / "id_ed25519.pub").exists()
|
||||||
|
assert (Path(ssh_dir) / "config").exists()
|
||||||
|
assert oct(os.stat(Path(ssh_dir) / "id_ed25519").st_mode)[-3:] == "600"
|
||||||
|
|
||||||
|
@patch("src.services.ssh_keys._get_fernet")
|
||||||
|
def test_sets_ownership_when_uid_gid_provided(self, mock_fernet, tmp_path) -> None:
|
||||||
|
mock_fernet.return_value.decrypt.return_value = b"private-key-content"
|
||||||
|
ssh_key = MagicMock()
|
||||||
|
ssh_key.private_key_encrypted = "enc"
|
||||||
|
ssh_key.public_key = "ssh-ed25519 AAA test@test"
|
||||||
|
|
||||||
|
with patch("os.chown") as mock_chown:
|
||||||
|
ssh_dir = prepare_ssh_key_files(str(tmp_path), ssh_key, uid=1001, gid=1001)
|
||||||
|
|
||||||
|
# os.chown is called for the directory and each of the 3 files
|
||||||
|
assert mock_chown.call_count == 4
|
||||||
|
# First call is the directory
|
||||||
|
assert mock_chown.call_args_list[0][0][1] == 1001
|
||||||
|
assert mock_chown.call_args_list[0][0][2] == 1001
|
||||||
|
|
||||||
|
@patch("src.services.ssh_keys._get_fernet")
|
||||||
|
def test_gracefully_handles_permission_error_on_chown(
|
||||||
|
self, mock_fernet, tmp_path
|
||||||
|
) -> None:
|
||||||
|
mock_fernet.return_value.decrypt.return_value = b"private-key-content"
|
||||||
|
ssh_key = MagicMock()
|
||||||
|
ssh_key.private_key_encrypted = "enc"
|
||||||
|
ssh_key.public_key = "ssh-ed25519 AAA test@test"
|
||||||
|
|
||||||
|
with patch("os.chown", side_effect=PermissionError("not allowed")):
|
||||||
|
# Should not raise
|
||||||
|
ssh_dir = prepare_ssh_key_files(str(tmp_path), ssh_key, uid=1001, gid=1001)
|
||||||
|
|
||||||
|
assert Path(ssh_dir).exists()
|
||||||
@@ -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,
|
||||||
@@ -701,14 +704,267 @@ class TestStartInstanceLegacyFallback:
|
|||||||
mock_execute_compose.assert_called_once()
|
mock_execute_compose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartInstanceSshPermissions:
|
||||||
|
"""SSH key mounts trigger permission fixes after container starts."""
|
||||||
|
|
||||||
|
@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.wait_for_container_running")
|
||||||
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
|
@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._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_manifest_instance_applies_ssh_permissions(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
|
mock_connect_network,
|
||||||
|
mock_get_container_id,
|
||||||
|
mock_execute_compose,
|
||||||
|
mock_wait_container,
|
||||||
|
mock_apply_ssh,
|
||||||
|
mock_prepare_ssh,
|
||||||
|
mock_write_compose,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_instance_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""Manifest instance with SSH keys calls apply_ssh_permissions."""
|
||||||
|
from src.models.tool_definition_manifest import ToolDefinitionManifest
|
||||||
|
|
||||||
|
manifest_id = uuid.uuid4()
|
||||||
|
ssh_key_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
|
mock_get_container_id.return_value = "abc123"
|
||||||
|
mock_connect_network.return_value = True
|
||||||
|
mock_wait_container.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"waited_seconds": 0.5,
|
||||||
|
}
|
||||||
|
mock_apply_ssh.return_value = {"success": True, "error": None}
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=fake_instance_id,
|
||||||
|
name="manifest-instance",
|
||||||
|
repository_id=fake_repo_id,
|
||||||
|
tool_type_id=fake_tool_type_id,
|
||||||
|
compose_path="/data/instances/manifest-instance/docker-compose.yml",
|
||||||
|
status="stopped",
|
||||||
|
clone_mode="mount",
|
||||||
|
ssh_key_ids=[ssh_key_id],
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="manifest-tool",
|
||||||
|
display_name="Manifest Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="manifest",
|
||||||
|
manifest_id=manifest_id,
|
||||||
|
dockerfile_template=None,
|
||||||
|
compose_template=None,
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
manifest_def = ToolDefinitionManifest(
|
||||||
|
id=manifest_id,
|
||||||
|
name="test-manifest",
|
||||||
|
display_name="Test Manifest",
|
||||||
|
interface_type="web",
|
||||||
|
manifest={"user": {"name": "user", "uid": 1001, "gid": 1001}},
|
||||||
|
)
|
||||||
|
ssh_key = SSHKey(
|
||||||
|
id=uuid.UUID(ssh_key_id),
|
||||||
|
user_id=fake_user_id,
|
||||||
|
name="test-key",
|
||||||
|
public_key="ssh-ed25519 AAA test@test",
|
||||||
|
private_key_encrypted="enc",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolInstance and pk == fake_instance_id:
|
||||||
|
return instance
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
if model is ToolDefinitionManifest and pk == manifest_id:
|
||||||
|
return manifest_def
|
||||||
|
if model is SSHKey and pk == uuid.UUID(ssh_key_id):
|
||||||
|
return ssh_key
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
with patch(
|
||||||
|
"src.api.tool_instances._prepare_manifest_instance"
|
||||||
|
) as mock_prepare:
|
||||||
|
mock_prepare.return_value = (
|
||||||
|
"headquarter/test:latest",
|
||||||
|
"services:\n app:\n image: test",
|
||||||
|
{"name": "test-manifest", "user": {"name": "user"}},
|
||||||
|
"/home/user",
|
||||||
|
)
|
||||||
|
result = await start_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
instance_id=fake_instance_id,
|
||||||
|
data=None,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "running"
|
||||||
|
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.wait_for_container_running")
|
||||||
|
@patch("src.api.tool_instances.execute_compose_command")
|
||||||
|
@patch("src.api.tool_instances.get_container_id")
|
||||||
|
@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._get_user")
|
||||||
|
@patch("src.api.tool_instances._get_owned_project")
|
||||||
|
async def test_legacy_instance_applies_ssh_permissions(
|
||||||
|
self,
|
||||||
|
mock_get_project,
|
||||||
|
mock_get_user,
|
||||||
|
mock_sanitize,
|
||||||
|
mock_ensure_web_bind,
|
||||||
|
mock_ensure_container_name,
|
||||||
|
mock_connect_network,
|
||||||
|
mock_get_container_id,
|
||||||
|
mock_execute_compose,
|
||||||
|
mock_wait_container,
|
||||||
|
mock_apply_ssh,
|
||||||
|
mock_prepare_ssh,
|
||||||
|
mock_session,
|
||||||
|
fake_user_id,
|
||||||
|
fake_project_id,
|
||||||
|
fake_repo_id,
|
||||||
|
fake_instance_id,
|
||||||
|
fake_tool_type_id,
|
||||||
|
) -> None:
|
||||||
|
"""Legacy instance with SSH keys calls apply_ssh_permissions."""
|
||||||
|
ssh_key_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
mock_get_user.return_value = AsyncMock()
|
||||||
|
mock_get_project.return_value = AsyncMock()
|
||||||
|
mock_execute_compose.return_value = (0, "started", "")
|
||||||
|
mock_get_container_id.return_value = "abc123"
|
||||||
|
mock_connect_network.return_value = True
|
||||||
|
mock_wait_container.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"status": "running",
|
||||||
|
"waited_seconds": 0.5,
|
||||||
|
}
|
||||||
|
mock_apply_ssh.return_value = {"success": True, "error": None}
|
||||||
|
|
||||||
|
instance = ToolInstance(
|
||||||
|
id=fake_instance_id,
|
||||||
|
name="legacy-instance",
|
||||||
|
repository_id=fake_repo_id,
|
||||||
|
tool_type_id=fake_tool_type_id,
|
||||||
|
compose_path="/data/instances/legacy-instance/docker-compose.yml",
|
||||||
|
status="stopped",
|
||||||
|
clone_mode="mount",
|
||||||
|
ssh_key_ids=[ssh_key_id],
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
|
tool_type = ToolType(
|
||||||
|
id=fake_tool_type_id,
|
||||||
|
name="legacy-tool",
|
||||||
|
display_name="Legacy Tool",
|
||||||
|
default_port=8080,
|
||||||
|
definition_type="legacy",
|
||||||
|
manifest_id=None,
|
||||||
|
dockerfile_template=None,
|
||||||
|
compose_template="services:\n app:\n image: nginx",
|
||||||
|
)
|
||||||
|
repo = GitRepository(
|
||||||
|
id=fake_repo_id,
|
||||||
|
project_id=fake_project_id,
|
||||||
|
name="test-repo",
|
||||||
|
path="/data/repos/test-repo",
|
||||||
|
remote_url=None,
|
||||||
|
ssh_key_id=None,
|
||||||
|
)
|
||||||
|
ssh_key = SSHKey(
|
||||||
|
id=uuid.UUID(ssh_key_id),
|
||||||
|
user_id=fake_user_id,
|
||||||
|
name="test-key",
|
||||||
|
public_key="ssh-ed25519 AAA test@test",
|
||||||
|
private_key_encrypted="enc",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get(model, pk):
|
||||||
|
if model is ToolInstance and pk == fake_instance_id:
|
||||||
|
return instance
|
||||||
|
if model is ToolType and pk == fake_tool_type_id:
|
||||||
|
return tool_type
|
||||||
|
if model is GitRepository and pk == fake_repo_id:
|
||||||
|
return repo
|
||||||
|
if model is User and pk == fake_user_id:
|
||||||
|
return User(id=fake_user_id, email="test@example.com")
|
||||||
|
if model is SSHKey and pk == uuid.UUID(ssh_key_id):
|
||||||
|
return ssh_key
|
||||||
|
return None
|
||||||
|
|
||||||
|
mock_session.get.side_effect = _get
|
||||||
|
|
||||||
|
with patch("os.path.exists", return_value=True):
|
||||||
|
with patch("src.api.tool_instances._modify_compose_file"):
|
||||||
|
result = await start_instance(
|
||||||
|
project_id=fake_project_id,
|
||||||
|
repo_id=fake_repo_id,
|
||||||
|
instance_id=fake_instance_id,
|
||||||
|
data=None,
|
||||||
|
user_id=fake_user_id,
|
||||||
|
session=mock_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "running"
|
||||||
|
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
|
||||||
|
|
||||||
|
|
||||||
class TestStartInstanceManifestBranch:
|
class TestStartInstanceManifestBranch:
|
||||||
"""Manifest branch is taken ONLY when definition_type == 'manifest'."""
|
"""Manifest branch is taken ONLY when definition_type == 'manifest'."""
|
||||||
|
|
||||||
@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")
|
||||||
@@ -721,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,
|
||||||
@@ -742,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,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export interface ConfigProfile {
|
|||||||
mounts: ConfigProfileMount[];
|
mounts: ConfigProfileMount[];
|
||||||
git_mounts: GitMount[];
|
git_mounts: GitMount[];
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
ssh_key_id: string | null;
|
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
includes: ConfigProfileInclude[];
|
includes: ConfigProfileInclude[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -53,7 +52,6 @@ export interface ResolvedProfile {
|
|||||||
mounts: ResolvedMount[];
|
mounts: ResolvedMount[];
|
||||||
git_mounts: GitMount[];
|
git_mounts: GitMount[];
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
ssh_key_id: string | null;
|
|
||||||
overrides: {
|
overrides: {
|
||||||
env_vars: Record<string, string>;
|
env_vars: Record<string, string>;
|
||||||
runtime_hints: Record<string, string>;
|
runtime_hints: Record<string, string>;
|
||||||
@@ -80,7 +78,6 @@ export interface CreateConfigProfileRequest {
|
|||||||
mounts?: ConfigProfileMount[];
|
mounts?: ConfigProfileMount[];
|
||||||
git_mounts?: GitMount[];
|
git_mounts?: GitMount[];
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
ssh_key_id?: string;
|
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +91,6 @@ export interface UpdateConfigProfileRequest {
|
|||||||
mounts?: ConfigProfileMount[];
|
mounts?: ConfigProfileMount[];
|
||||||
git_mounts?: GitMount[];
|
git_mounts?: GitMount[];
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
ssh_key_id?: string;
|
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ToolInstance {
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
port: number | null;
|
port: number | null;
|
||||||
selected_config_profile_id: string | null;
|
selected_config_profile_id: string | null;
|
||||||
|
ssh_key_ids: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +53,8 @@ export async function createInstance(
|
|||||||
cloneMode?: string,
|
cloneMode?: string,
|
||||||
branch?: string,
|
branch?: string,
|
||||||
newBranch?: string,
|
newBranch?: string,
|
||||||
configProfileId?: string
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[]
|
||||||
): Promise<ToolInstance> {
|
): Promise<ToolInstance> {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||||
@@ -63,6 +65,7 @@ export async function createInstance(
|
|||||||
branch: branch || undefined,
|
branch: branch || undefined,
|
||||||
new_branch: newBranch || undefined,
|
new_branch: newBranch || undefined,
|
||||||
config_profile_id: configProfileId,
|
config_profile_id: configProfileId,
|
||||||
|
ssh_key_ids: sshKeyIds || [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -73,12 +76,13 @@ export async function startInstance(
|
|||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
retries = 2
|
retries = 2
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,7 +90,7 @@ export async function startInstance(
|
|||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return startInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
return startInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -108,12 +112,13 @@ export async function restartInstance(
|
|||||||
repoId: string,
|
repoId: string,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
retries = 2
|
retries = 2
|
||||||
): Promise<{ status: string; url?: string }> {
|
): Promise<{ status: string; url?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||||
{ config_profile_id: configProfileId }
|
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,7 +126,7 @@ export async function restartInstance(
|
|||||||
const axiosError = error as AxiosError;
|
const axiosError = error as AxiosError;
|
||||||
if (retries > 0 && !axiosError.response) {
|
if (retries > 0 && !axiosError.response) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
return restartInstance(projectId, repoId, instanceId, configProfileId, retries - 1);
|
return restartInstance(projectId, repoId, instanceId, configProfileId, sshKeyIds, retries - 1);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface UserConfig {
|
|||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
last_session_id: string | null;
|
last_session_id: string | null;
|
||||||
|
notification_toast_level?: "all" | "errors" | "none";
|
||||||
|
notification_mute_categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserConfigUpdate {
|
export interface UserConfigUpdate {
|
||||||
@@ -14,6 +16,8 @@ export interface UserConfigUpdate {
|
|||||||
git_user_name?: string | null;
|
git_user_name?: string | null;
|
||||||
git_user_email?: string | null;
|
git_user_email?: string | null;
|
||||||
last_session_id?: string | null;
|
last_session_id?: string | null;
|
||||||
|
notification_toast_level?: "all" | "errors" | "none";
|
||||||
|
notification_mute_categories?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUserConfig = async (): Promise<UserConfig> => {
|
export const getUserConfig = async (): Promise<UserConfig> => {
|
||||||
@@ -21,7 +25,9 @@ export const getUserConfig = async (): Promise<UserConfig> => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUserConfig = async (data: UserConfigUpdate): Promise<UserConfig> => {
|
export const updateUserConfig = async (
|
||||||
|
data: UserConfigUpdate,
|
||||||
|
): Promise<UserConfig> => {
|
||||||
const response = await apiClient.patch<UserConfig>("/users/me/config", data);
|
const response = await apiClient.patch<UserConfig>("/users/me/config", data);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const CreateSessionForm = ({
|
|||||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
||||||
|
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const [branches, setBranches] = useState<Branch[]>([]);
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
@@ -60,9 +61,8 @@ export const CreateSessionForm = ({
|
|||||||
const [progress, setProgress] = useState("");
|
const [progress, setProgress] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load SSH keys when clone mode is shown
|
// Load SSH keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCloneMode) return;
|
|
||||||
const loadKeys = async () => {
|
const loadKeys = async () => {
|
||||||
try {
|
try {
|
||||||
const keys = await listSSHKeys();
|
const keys = await listSSHKeys();
|
||||||
@@ -72,7 +72,7 @@ export const CreateSessionForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
void loadKeys();
|
void loadKeys();
|
||||||
}, [showCloneMode]);
|
}, []);
|
||||||
|
|
||||||
// Load config profiles when tool type is selected
|
// Load config profiles when tool type is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -166,11 +166,18 @@ export const CreateSessionForm = ({
|
|||||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||||
? newBranchName
|
? newBranchName
|
||||||
: undefined,
|
: undefined,
|
||||||
selectedConfigProfile || undefined
|
selectedConfigProfile || undefined,
|
||||||
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
setProgress("Starting container...");
|
setProgress("Starting container...");
|
||||||
await startInstance(projectId, repoId, instance.id);
|
await startInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instance.id,
|
||||||
|
selectedConfigProfile || undefined,
|
||||||
|
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
if (!fixedProjectId) setSelectedProject("");
|
if (!fixedProjectId) setSelectedProject("");
|
||||||
@@ -183,6 +190,7 @@ export const CreateSessionForm = ({
|
|||||||
setNewBranchName("");
|
setNewBranchName("");
|
||||||
setBaseBranch("");
|
setBaseBranch("");
|
||||||
setBranches([]);
|
setBranches([]);
|
||||||
|
setSelectedSshKeyIds([]);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
|
||||||
onSuccess?.(instance);
|
onSuccess?.(instance);
|
||||||
@@ -344,8 +352,54 @@ export const CreateSessionForm = ({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 5: Clone Mode & Branch */}
|
{/* Step 5: SSH Keys */}
|
||||||
{showCloneMode && hasToolType && renderStep("Repository Access", 5, true, false,
|
{hasToolType && renderStep("SSH Keys (optional)", 5, true, false,
|
||||||
|
<div className="form-field">
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||||
|
{sshKeys.length === 0 && (
|
||||||
|
<span className="muted">No SSH keys configured.</span>
|
||||||
|
)}
|
||||||
|
{sshKeys.map((key) => (
|
||||||
|
<label
|
||||||
|
key={key.id}
|
||||||
|
className="checkbox-label"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
padding: "0.375rem 0.75rem",
|
||||||
|
background: "var(--panel)",
|
||||||
|
borderRadius: "0.375rem",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSshKeyIds.includes(key.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedSshKeyIds((prev) => [...prev, key.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedSshKeyIds((prev) =>
|
||||||
|
prev.filter((id) => id !== key.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
{key.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hint" style={{ marginTop: "0.5rem" }}>
|
||||||
|
Selected keys will be mounted into the container at ~/.ssh
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 6: Clone Mode & Branch */}
|
||||||
|
{showCloneMode && hasToolType && renderStep("Repository Access", 6, true, false,
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<div className="radio-group">
|
<div className="radio-group">
|
||||||
@@ -468,8 +522,8 @@ export const CreateSessionForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 6: Display Name */}
|
{/* Step 7: Display Name */}
|
||||||
{hasToolType && renderStep("Display Name (optional)", 6, true, !!displayName,
|
{hasToolType && renderStep("Display Name (optional)", 7, true, !!displayName,
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, act } from "@testing-library/react";
|
||||||
|
import { EventToastBridge } from "./event-toast-bridge";
|
||||||
|
import { useEventContext } from "../state/events";
|
||||||
|
import { getUserConfig } from "../api/settings";
|
||||||
|
import { handleEventToast } from "./toast-rules";
|
||||||
|
import type { InstanceEventPayload } from "../types/events";
|
||||||
|
|
||||||
|
vi.mock("../state/events", () => ({
|
||||||
|
useEventContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/settings", () => ({
|
||||||
|
getUserConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./toast-rules", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./toast-rules")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
handleEventToast: vi.fn(),
|
||||||
|
clearToastDedup: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedUseEventContext = vi.mocked(useEventContext);
|
||||||
|
const mockedGetUserConfig = vi.mocked(getUserConfig);
|
||||||
|
const mockedHandleEventToast = vi.mocked(handleEventToast);
|
||||||
|
|
||||||
|
function makeEvent(
|
||||||
|
eventType: string,
|
||||||
|
overrides?: Partial<InstanceEventPayload>,
|
||||||
|
): InstanceEventPayload {
|
||||||
|
return {
|
||||||
|
event: eventType,
|
||||||
|
instance_id: "i-1",
|
||||||
|
status: undefined,
|
||||||
|
message: undefined,
|
||||||
|
metadata: {},
|
||||||
|
timestamp: "2026-05-29T10:00:00Z",
|
||||||
|
correlation_id: "c1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("EventToastBridge preference checks", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
theme: "system",
|
||||||
|
default_editor: null,
|
||||||
|
git_user_name: null,
|
||||||
|
git_user_email: null,
|
||||||
|
last_session_id: null,
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toast when level is all and category not muted", async () => {
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses toast when level is none", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "none",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses info toast when level is errors", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "errors",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when level is errors", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "errors",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.error");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses toast when category is muted", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: ["instance"],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies preference change immediately via custom event", async () => {
|
||||||
|
const event1 = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event1],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
const { rerender } = render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("userconfig:updated", {
|
||||||
|
detail: { notification_toast_level: "none" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const event2 = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event1, event2],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
rerender(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("muted category overrides all level", async () => {
|
||||||
|
mockedGetUserConfig.mockResolvedValue({
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: ["instance"],
|
||||||
|
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||||
|
const event = makeEvent("instance.error");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplication still works with preferences", async () => {
|
||||||
|
const event = makeEvent("instance.started");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event, event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmapped event defaults to system/info and shows when level is all", async () => {
|
||||||
|
const event = makeEvent("system.announcement");
|
||||||
|
mockedUseEventContext.mockReturnValue({
|
||||||
|
events: [event],
|
||||||
|
connected: false,
|
||||||
|
reconnectCount: 0,
|
||||||
|
});
|
||||||
|
render(<EventToastBridge />);
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,77 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useEventContext } from "../state/events";
|
import { useEventContext } from "../state/events";
|
||||||
import { handleEventToast } from "./toast-rules";
|
import {
|
||||||
|
handleEventToast,
|
||||||
|
mapEventToCategory,
|
||||||
|
mapEventToSeverity,
|
||||||
|
} from "./toast-rules";
|
||||||
|
import { getUserConfig } from "../api/settings";
|
||||||
|
import type { UserConfig } from "../api/settings";
|
||||||
|
|
||||||
|
interface ToastConfig {
|
||||||
|
notification_toast_level: string;
|
||||||
|
notification_mute_categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function EventToastBridge(): JSX.Element | null {
|
export function EventToastBridge(): JSX.Element | null {
|
||||||
const { events } = useEventContext();
|
const { events } = useEventContext();
|
||||||
const processedRef = useRef<Set<string>>(new Set());
|
const processedRef = useRef<Set<string>>(new Set());
|
||||||
|
const [config, setConfig] = useState<ToastConfig | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
getUserConfig()
|
||||||
|
.then((c) => {
|
||||||
|
setConfig({
|
||||||
|
notification_toast_level: c.notification_toast_level ?? "all",
|
||||||
|
notification_mute_categories: c.notification_mute_categories ?? [],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setConfig({
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent<Partial<UserConfig>>).detail;
|
||||||
|
if (detail) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
notification_toast_level:
|
||||||
|
detail.notification_toast_level ??
|
||||||
|
prev?.notification_toast_level ??
|
||||||
|
"all",
|
||||||
|
notification_mute_categories:
|
||||||
|
detail.notification_mute_categories ??
|
||||||
|
prev?.notification_mute_categories ??
|
||||||
|
[],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("userconfig:updated", handler);
|
||||||
|
return () => window.removeEventListener("userconfig:updated", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const key = `${event.correlation_id}:${event.timestamp}`;
|
const key = `${event.correlation_id}:${event.timestamp}`;
|
||||||
if (processedRef.current.has(key)) continue;
|
if (processedRef.current.has(key)) continue;
|
||||||
processedRef.current.add(key);
|
processedRef.current.add(key);
|
||||||
|
|
||||||
|
const category = mapEventToCategory(event);
|
||||||
|
const severity = mapEventToSeverity(event);
|
||||||
|
|
||||||
|
if (config.notification_toast_level === "none") continue;
|
||||||
|
if (config.notification_toast_level === "errors" && severity !== "error")
|
||||||
|
continue;
|
||||||
|
if (config.notification_mute_categories.includes(category)) continue;
|
||||||
|
|
||||||
handleEventToast(event);
|
handleEventToast(event);
|
||||||
}
|
}
|
||||||
}, [events]);
|
}, [events, config]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
|||||||
interface GitMountEditorProps {
|
interface GitMountEditorProps {
|
||||||
mounts: GitMount[];
|
mounts: GitMount[];
|
||||||
onChange: (mounts: GitMount[]) => void;
|
onChange: (mounts: GitMount[]) => void;
|
||||||
defaultSshKeyId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMount(mount: GitMount): GitMount {
|
function normalizeMount(mount: GitMount): GitMount {
|
||||||
@@ -34,7 +33,10 @@ function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
|||||||
return mounts.map(normalizeMount);
|
return mounts.map(normalizeMount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEditorProps) => {
|
export const GitMountEditor = ({
|
||||||
|
mounts,
|
||||||
|
onChange,
|
||||||
|
}: GitMountEditorProps) => {
|
||||||
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
||||||
normalizeMounts(mounts),
|
normalizeMounts(mounts),
|
||||||
);
|
);
|
||||||
@@ -93,7 +95,6 @@ export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEd
|
|||||||
mount={mount}
|
mount={mount}
|
||||||
onSave={(updated) => handleUpdate(index, updated)}
|
onSave={(updated) => handleUpdate(index, updated)}
|
||||||
onCancel={() => setEditingIndex(null)}
|
onCancel={() => setEditingIndex(null)}
|
||||||
defaultSshKeyId={defaultSshKeyId}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -185,7 +186,6 @@ export const GitMountEditor = ({ mounts, onChange, defaultSshKeyId }: GitMountEd
|
|||||||
}}
|
}}
|
||||||
onSave={handleAdd}
|
onSave={handleAdd}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
defaultSshKeyId={defaultSshKeyId}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -206,7 +206,6 @@ interface GitMountFormProps {
|
|||||||
mount: GitMount;
|
mount: GitMount;
|
||||||
onSave: (mount: GitMount) => void;
|
onSave: (mount: GitMount) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
defaultSshKeyId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ValidationState =
|
type ValidationState =
|
||||||
@@ -216,7 +215,11 @@ type ValidationState =
|
|||||||
| { status: "suggestion"; suggestedUrl: string; message: string }
|
| { status: "suggestion"; suggestedUrl: string; message: string }
|
||||||
| { status: "invalid"; message: string };
|
| { status: "invalid"; message: string };
|
||||||
|
|
||||||
const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountFormProps) => {
|
const GitMountForm = ({
|
||||||
|
mount,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: GitMountFormProps) => {
|
||||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||||
const [branch, setBranch] = useState(mount.branch || "");
|
const [branch, setBranch] = useState(mount.branch || "");
|
||||||
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
||||||
@@ -245,10 +248,7 @@ const GitMountForm = ({ mount, onSave, onCancel, defaultSshKeyId }: GitMountForm
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await validateGitUrl(
|
const result = await validateGitUrl(remoteUrl.trim());
|
||||||
remoteUrl.trim(),
|
|
||||||
defaultSshKeyId,
|
|
||||||
);
|
|
||||||
if (result.valid && result.branches) {
|
if (result.valid && result.branches) {
|
||||||
setValidation({
|
setValidation({
|
||||||
status: "valid",
|
status: "valid",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type { ToolType } from "../api/tool_types";
|
import type { ToolType } from "../api/tool_types";
|
||||||
import { CreateSessionForm } from "./create-session-form";
|
import { CreateSessionForm } from "./create-session-form";
|
||||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||||
|
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||||
import { useEventContext } from "../state/events";
|
import { useEventContext } from "../state/events";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
@@ -47,6 +48,10 @@ export const InstanceList = ({
|
|||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
||||||
|
const [selectedSshKeyIdsForAction, setSelectedSshKeyIdsForAction] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||||
|
|
||||||
// Per-instance busy state for actions
|
// Per-instance busy state for actions
|
||||||
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
||||||
@@ -105,8 +110,12 @@ export const InstanceList = ({
|
|||||||
const loadConfigProfiles = useCallback(
|
const loadConfigProfiles = useCallback(
|
||||||
async (toolTypeId: string) => {
|
async (toolTypeId: string) => {
|
||||||
try {
|
try {
|
||||||
const profiles = await listConfigProfiles(projectId, toolTypeId);
|
const [profiles, keys] = await Promise.all([
|
||||||
|
listConfigProfiles(projectId, toolTypeId),
|
||||||
|
listSSHKeys(),
|
||||||
|
]);
|
||||||
setConfigProfiles(profiles);
|
setConfigProfiles(profiles);
|
||||||
|
setSshKeys(keys);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -114,12 +123,23 @@ export const InstanceList = ({
|
|||||||
[projectId],
|
[projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStart = async (instanceId: string, configProfileId?: string) => {
|
const handleStart = async (
|
||||||
|
instanceId: string,
|
||||||
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
|
) => {
|
||||||
setBusyInstanceId(instanceId);
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await startInstance(projectId, repoId, instanceId, configProfileId);
|
await startInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
);
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to start instance");
|
setError("Failed to start instance");
|
||||||
@@ -144,12 +164,20 @@ export const InstanceList = ({
|
|||||||
const handleRestart = async (
|
const handleRestart = async (
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
configProfileId?: string,
|
configProfileId?: string,
|
||||||
|
sshKeyIds?: string[],
|
||||||
) => {
|
) => {
|
||||||
setBusyInstanceId(instanceId);
|
setBusyInstanceId(instanceId);
|
||||||
try {
|
try {
|
||||||
await restartInstance(projectId, repoId, instanceId, configProfileId);
|
await restartInstance(
|
||||||
|
projectId,
|
||||||
|
repoId,
|
||||||
|
instanceId,
|
||||||
|
configProfileId,
|
||||||
|
sshKeyIds,
|
||||||
|
);
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
await loadInstances();
|
await loadInstances();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to restart instance");
|
setError("Failed to restart instance");
|
||||||
@@ -294,12 +322,59 @@ export const InstanceList = ({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.25rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sshKeys.map((key) => (
|
||||||
|
<label
|
||||||
|
key={key.id}
|
||||||
|
className="checkbox-label"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSshKeyIdsForAction.includes(
|
||||||
|
key.id,
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
(prev) => [...prev, key.id],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
(prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(id) =>
|
||||||
|
id !== key.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{key.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button small"
|
className="primary-button small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void handleStart(
|
void handleStart(
|
||||||
instance.id,
|
instance.id,
|
||||||
selectedProfileForAction || undefined,
|
selectedProfileForAction || undefined,
|
||||||
|
selectedSshKeyIdsForAction.length > 0
|
||||||
|
? selectedSshKeyIdsForAction
|
||||||
|
: undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -313,6 +388,7 @@ export const InstanceList = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
@@ -334,6 +410,9 @@ export const InstanceList = ({
|
|||||||
setSelectedProfileForAction(
|
setSelectedProfileForAction(
|
||||||
instance.selected_config_profile_id || "",
|
instance.selected_config_profile_id || "",
|
||||||
);
|
);
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
instance.ssh_key_ids || [],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
@@ -391,12 +470,59 @@ export const InstanceList = ({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.25rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sshKeys.map((key) => (
|
||||||
|
<label
|
||||||
|
key={key.id}
|
||||||
|
className="checkbox-label"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSshKeyIdsForAction.includes(
|
||||||
|
key.id,
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
(prev) => [...prev, key.id],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
(prev) =>
|
||||||
|
prev.filter(
|
||||||
|
(id) =>
|
||||||
|
id !== key.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{key.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="primary-button small"
|
className="primary-button small"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void handleRestart(
|
void handleRestart(
|
||||||
instance.id,
|
instance.id,
|
||||||
selectedProfileForAction || undefined,
|
selectedProfileForAction || undefined,
|
||||||
|
selectedSshKeyIdsForAction.length > 0
|
||||||
|
? selectedSshKeyIdsForAction
|
||||||
|
: undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -410,6 +536,7 @@ export const InstanceList = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setProfileSelectInstanceId(null);
|
setProfileSelectInstanceId(null);
|
||||||
setSelectedProfileForAction("");
|
setSelectedProfileForAction("");
|
||||||
|
setSelectedSshKeyIdsForAction([]);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
@@ -431,6 +558,9 @@ export const InstanceList = ({
|
|||||||
setSelectedProfileForAction(
|
setSelectedProfileForAction(
|
||||||
instance.selected_config_profile_id || "",
|
instance.selected_config_profile_id || "",
|
||||||
);
|
);
|
||||||
|
setSelectedSshKeyIdsForAction(
|
||||||
|
instance.ssh_key_ids || [],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyInstanceId === instance.id}
|
disabled={busyInstanceId === instance.id}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,148 +1,69 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { handleEventToast, clearToastDedup } from "./toast-rules";
|
import { mapEventToCategory, mapEventToSeverity } from "./toast-rules";
|
||||||
import type { InstanceEventPayload } from "../types/events";
|
import type { InstanceEventPayload } from "../types/events";
|
||||||
|
|
||||||
const mockToastInfo = vi.fn();
|
function makeEvent(
|
||||||
const mockToastSuccess = vi.fn();
|
event: string,
|
||||||
const mockToastWarning = vi.fn();
|
overrides?: Partial<InstanceEventPayload>,
|
||||||
const mockToastError = vi.fn();
|
): InstanceEventPayload {
|
||||||
|
return {
|
||||||
vi.mock("../state/toast", () => ({
|
event,
|
||||||
toast: {
|
instance_id: "i-1",
|
||||||
info: (...args: unknown[]) => mockToastInfo(...args),
|
status: undefined,
|
||||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
message: undefined,
|
||||||
warning: (...args: unknown[]) => mockToastWarning(...args),
|
|
||||||
error: (...args: unknown[]) => mockToastError(...args),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("toast-rules", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearToastDedup();
|
|
||||||
mockToastInfo.mockClear();
|
|
||||||
mockToastSuccess.mockClear();
|
|
||||||
mockToastWarning.mockClear();
|
|
||||||
mockToastError.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps instance.started to info toast", () => {
|
|
||||||
const event: InstanceEventPayload = {
|
|
||||||
event: "instance.started",
|
|
||||||
instance_id: "inst-1",
|
|
||||||
status: "starting",
|
|
||||||
message: "Container starting...",
|
|
||||||
metadata: {},
|
metadata: {},
|
||||||
timestamp: "2026-05-28T12:00:00Z",
|
timestamp: "2026-05-29T10:00:00Z",
|
||||||
correlation_id: "corr-1",
|
correlation_id: "c1",
|
||||||
|
...overrides,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
handleEventToast(event);
|
describe("mapEventToCategory", () => {
|
||||||
expect(mockToastInfo).toHaveBeenCalledWith("Container starting...", {
|
it('returns "instance" for instance.* events', () => {
|
||||||
duration: 3000,
|
expect(mapEventToCategory(makeEvent("instance.started"))).toBe("instance");
|
||||||
});
|
expect(mapEventToCategory(makeEvent("instance.error"))).toBe("instance");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps health_changed to running to success toast", () => {
|
it('returns "health" for health.* events', () => {
|
||||||
const event: InstanceEventPayload = {
|
expect(mapEventToCategory(makeEvent("health.error"))).toBe("health");
|
||||||
event: "instance.health_changed",
|
|
||||||
instance_id: "inst-1",
|
|
||||||
status: "running",
|
|
||||||
message: "Container is running",
|
|
||||||
metadata: { previous_status: "starting" },
|
|
||||||
timestamp: "2026-05-28T12:00:00Z",
|
|
||||||
correlation_id: "corr-1",
|
|
||||||
};
|
|
||||||
|
|
||||||
handleEventToast(event);
|
|
||||||
expect(mockToastSuccess).toHaveBeenCalledWith("Container running", {
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps health_changed to unhealthy to warning toast", () => {
|
it('returns "system" for unknown events', () => {
|
||||||
const event: InstanceEventPayload = {
|
expect(mapEventToCategory(makeEvent("system.announcement"))).toBe("system");
|
||||||
event: "instance.health_changed",
|
});
|
||||||
instance_id: "inst-1",
|
});
|
||||||
status: "unhealthy",
|
|
||||||
message: "Container is unhealthy",
|
describe("mapEventToSeverity", () => {
|
||||||
metadata: { previous_status: "running" },
|
it("returns error for instance.error and health.error", () => {
|
||||||
timestamp: "2026-05-28T12:00:00Z",
|
expect(mapEventToSeverity(makeEvent("instance.error"))).toBe("error");
|
||||||
correlation_id: "corr-1",
|
expect(mapEventToSeverity(makeEvent("health.error"))).toBe("error");
|
||||||
};
|
});
|
||||||
|
|
||||||
handleEventToast(event);
|
it("returns warning for unhealthy health changes", () => {
|
||||||
expect(mockToastWarning).toHaveBeenCalledWith("Container unhealthy", {
|
expect(
|
||||||
duration: 5000,
|
mapEventToSeverity(
|
||||||
});
|
makeEvent("instance.health_changed", { status: "unhealthy" }),
|
||||||
});
|
),
|
||||||
|
).toBe("warning");
|
||||||
it("maps instance.error to error toast with exit code", () => {
|
});
|
||||||
const event: InstanceEventPayload = {
|
|
||||||
event: "instance.error",
|
it("returns success for recovery to running", () => {
|
||||||
instance_id: "inst-1",
|
expect(
|
||||||
status: "error",
|
mapEventToSeverity(
|
||||||
message: "Container crashed",
|
makeEvent("instance.health_changed", { status: "running" }),
|
||||||
metadata: { exit_code: 137 },
|
),
|
||||||
timestamp: "2026-05-28T12:00:00Z",
|
).toBe("success");
|
||||||
correlation_id: "corr-1",
|
});
|
||||||
};
|
|
||||||
|
it("returns info for lifecycle events", () => {
|
||||||
handleEventToast(event);
|
expect(mapEventToSeverity(makeEvent("instance.created"))).toBe("info");
|
||||||
expect(mockToastError).toHaveBeenCalledWith(
|
expect(mapEventToSeverity(makeEvent("instance.started"))).toBe("info");
|
||||||
"Container crashed (exit code: 137)",
|
expect(mapEventToSeverity(makeEvent("instance.stopped"))).toBe("info");
|
||||||
{ duration: 10000 },
|
expect(mapEventToSeverity(makeEvent("instance.restarted"))).toBe("info");
|
||||||
);
|
expect(mapEventToSeverity(makeEvent("instance.deleted"))).toBe("info");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps instance.error to error toast without exit code", () => {
|
it("returns info for unmapped events", () => {
|
||||||
const event: InstanceEventPayload = {
|
expect(mapEventToSeverity(makeEvent("unknown.event"))).toBe("info");
|
||||||
event: "instance.error",
|
|
||||||
instance_id: "inst-1",
|
|
||||||
status: "error",
|
|
||||||
message: "Build failed",
|
|
||||||
metadata: {},
|
|
||||||
timestamp: "2026-05-28T12:00:00Z",
|
|
||||||
correlation_id: "corr-1",
|
|
||||||
};
|
|
||||||
|
|
||||||
handleEventToast(event);
|
|
||||||
expect(mockToastError).toHaveBeenCalledWith("Build failed", {
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deduplicates within one second", () => {
|
|
||||||
const event: InstanceEventPayload = {
|
|
||||||
event: "instance.started",
|
|
||||||
instance_id: "inst-1",
|
|
||||||
status: "starting",
|
|
||||||
message: "Container starting...",
|
|
||||||
metadata: {},
|
|
||||||
timestamp: "2026-05-28T12:00:00Z",
|
|
||||||
correlation_id: "corr-1",
|
|
||||||
};
|
|
||||||
|
|
||||||
handleEventToast(event);
|
|
||||||
handleEventToast(event);
|
|
||||||
expect(mockToastInfo).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows duplicate after one second", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const event: InstanceEventPayload = {
|
|
||||||
event: "instance.started",
|
|
||||||
instance_id: "inst-1",
|
|
||||||
status: "starting",
|
|
||||||
message: "Container starting...",
|
|
||||||
metadata: {},
|
|
||||||
timestamp: "2026-05-28T12:00:00Z",
|
|
||||||
correlation_id: "corr-1",
|
|
||||||
};
|
|
||||||
|
|
||||||
handleEventToast(event);
|
|
||||||
vi.advanceTimersByTime(1100);
|
|
||||||
handleEventToast(event);
|
|
||||||
expect(mockToastInfo).toHaveBeenCalledTimes(2);
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,32 @@ function shouldShowToast(instanceId: string, eventType: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapEventToCategory(event: InstanceEventPayload): string {
|
||||||
|
if (event.event.startsWith("instance.")) return "instance";
|
||||||
|
if (event.event.startsWith("health.")) return "health";
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapEventToSeverity(
|
||||||
|
event: InstanceEventPayload,
|
||||||
|
): "info" | "warning" | "error" | "success" {
|
||||||
|
switch (event.event) {
|
||||||
|
case "instance.error":
|
||||||
|
case "health.error":
|
||||||
|
return "error";
|
||||||
|
case "instance.health_changed":
|
||||||
|
return event.status === "unhealthy" ? "warning" : "success";
|
||||||
|
case "instance.created":
|
||||||
|
case "instance.started":
|
||||||
|
case "instance.stopped":
|
||||||
|
case "instance.restarted":
|
||||||
|
case "instance.deleted":
|
||||||
|
return "info";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function handleEventToast(event: InstanceEventPayload): void {
|
export function handleEventToast(event: InstanceEventPayload): void {
|
||||||
const { event: eventType, instance_id, status, message, metadata } = event;
|
const { event: eventType, instance_id, status, message, metadata } = event;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+151
-20
@@ -1,7 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||||
|
|
||||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
import {
|
||||||
|
getUserConfig,
|
||||||
|
updateUserConfig,
|
||||||
|
type UserConfig,
|
||||||
|
type UserConfigUpdate,
|
||||||
|
} from "../api/settings";
|
||||||
import { ErrorState, LoadingState } from "../components/data-states";
|
import { ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
@@ -17,34 +22,62 @@ const THEME_OPTIONS = [
|
|||||||
{ value: "dark", label: "Dark" },
|
{ value: "dark", label: "Dark" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TOAST_LEVEL_OPTIONS = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "errors", label: "Errors only" },
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MUTE_CATEGORIES = ["instance", "system", "health", "security"];
|
||||||
|
|
||||||
type SettingsOutletContext = {
|
type SettingsOutletContext = {
|
||||||
config: UserConfig;
|
config: UserConfig;
|
||||||
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
handleChange: (
|
||||||
|
key: keyof UserConfigUpdate,
|
||||||
|
value: string | string[] | null,
|
||||||
|
) => void;
|
||||||
handleSave: () => Promise<void>;
|
handleSave: () => Promise<void>;
|
||||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: loadedConfig, status, reload } = useAsyncData<UserConfig>(getUserConfig, []);
|
const {
|
||||||
|
data: loadedConfig,
|
||||||
|
status,
|
||||||
|
reload,
|
||||||
|
} = useAsyncData<UserConfig>(getUserConfig, []);
|
||||||
const [config, setConfig] = useState<UserConfig>({
|
const [config, setConfig] = useState<UserConfig>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
default_editor: null,
|
default_editor: null,
|
||||||
git_user_name: null,
|
git_user_name: null,
|
||||||
git_user_email: null,
|
git_user_email: null,
|
||||||
last_session_id: null,
|
last_session_id: null,
|
||||||
|
notification_toast_level: "all",
|
||||||
|
notification_mute_categories: [],
|
||||||
});
|
});
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
const [saveStatus, setSaveStatus] = useState<
|
||||||
|
"idle" | "saving" | "saved" | "error"
|
||||||
|
>("idle");
|
||||||
|
|
||||||
// Sync loaded config into local editable state
|
// Sync loaded config into local editable state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadedConfig) {
|
if (loadedConfig) {
|
||||||
setConfig(loadedConfig);
|
setConfig({
|
||||||
|
...loadedConfig,
|
||||||
|
notification_toast_level:
|
||||||
|
loadedConfig.notification_toast_level ?? "all",
|
||||||
|
notification_mute_categories:
|
||||||
|
loadedConfig.notification_mute_categories ?? [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [loadedConfig]);
|
}, [loadedConfig]);
|
||||||
|
|
||||||
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
const handleChange = (
|
||||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
key: keyof UserConfigUpdate,
|
||||||
|
value: string | string[] | null,
|
||||||
|
) => {
|
||||||
|
setConfig((prev) => ({ ...prev, [key]: value }) as UserConfig);
|
||||||
setSaveStatus("idle");
|
setSaveStatus("idle");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,9 +89,14 @@ export const SettingsPage = () => {
|
|||||||
default_editor: config.default_editor,
|
default_editor: config.default_editor,
|
||||||
git_user_name: config.git_user_name,
|
git_user_name: config.git_user_name,
|
||||||
git_user_email: config.git_user_email,
|
git_user_email: config.git_user_email,
|
||||||
|
notification_toast_level: config.notification_toast_level,
|
||||||
|
notification_mute_categories: config.notification_mute_categories,
|
||||||
};
|
};
|
||||||
const updated = await updateUserConfig(update);
|
const updated = await updateUserConfig(update);
|
||||||
setConfig(updated);
|
setConfig(updated);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("userconfig:updated", { detail: updated }),
|
||||||
|
);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
if (updated.theme === "system") {
|
if (updated.theme === "system") {
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
@@ -72,7 +110,11 @@ export const SettingsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return <section className="stack"><LoadingState message="Loading settings..." /></section>;
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<LoadingState message="Loading settings..." />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
@@ -84,7 +126,9 @@ export const SettingsPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = location.pathname.split("/").filter(Boolean);
|
const parts = location.pathname.split("/").filter(Boolean);
|
||||||
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
const activePath = location.pathname.endsWith("/settings")
|
||||||
|
? "general"
|
||||||
|
: (parts[parts.length - 1] ?? "general");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="stack settings-page">
|
<section className="stack settings-page">
|
||||||
@@ -93,7 +137,9 @@ export const SettingsPage = () => {
|
|||||||
<p className="eyebrow">Configuration</p>
|
<p className="eyebrow">Configuration</p>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">General preferences, SSH keys, and config profiles.</p>
|
<p className="muted">
|
||||||
|
General preferences, SSH keys, and config profiles.
|
||||||
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav className="settings-tabs" aria-label="Settings sections">
|
<nav className="settings-tabs" aria-label="Settings sections">
|
||||||
@@ -116,37 +162,122 @@ export const SettingsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const GeneralSettingsTab = () => {
|
export const GeneralSettingsTab = () => {
|
||||||
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
const { config, handleChange, handleSave, saveStatus } =
|
||||||
|
useOutletContext<SettingsOutletContext>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<h2>General</h2>
|
<h2>General</h2>
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
Theme
|
Theme
|
||||||
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
<select
|
||||||
|
value={config.theme}
|
||||||
|
onChange={(e) => handleChange("theme", e.target.value)}
|
||||||
|
>
|
||||||
{THEME_OPTIONS.map((opt) => (
|
{THEME_OPTIONS.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
Git user name
|
Git user name
|
||||||
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.git_user_name ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("git_user_name", e.target.value || null)
|
||||||
|
}
|
||||||
|
placeholder="Your git commit name"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
Git user email
|
Git user email
|
||||||
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
<input
|
||||||
|
type="email"
|
||||||
|
value={config.git_user_email ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("git_user_email", e.target.value || null)
|
||||||
|
}
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
Default editor
|
Default editor
|
||||||
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.default_editor ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("default_editor", e.target.value || null)
|
||||||
|
}
|
||||||
|
placeholder="e.g., vscode, vim, cursor"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<h3>Notifications</h3>
|
||||||
|
<label className="form-field">
|
||||||
|
Toast level
|
||||||
|
<select
|
||||||
|
value={config.notification_toast_level ?? "all"}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("notification_toast_level", e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{TOAST_LEVEL_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset className="form-field">
|
||||||
|
<legend>Mute categories</legend>
|
||||||
|
<div className="stack-sm">
|
||||||
|
{MUTE_CATEGORIES.map((cat) => (
|
||||||
|
<label
|
||||||
|
key={cat}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(config.notification_mute_categories ?? []).includes(
|
||||||
|
cat,
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = config.notification_mute_categories ?? [];
|
||||||
|
const next = e.target.checked
|
||||||
|
? [...current, cat]
|
||||||
|
: current.filter((c) => c !== cat);
|
||||||
|
handleChange("notification_mute_categories", next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cat}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
<button
|
||||||
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
className="primary-button"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{saveStatus === "saving" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" /> Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="save" size="sm" /> Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
{saveStatus === "saved" && (
|
||||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
<span className="success-text">Settings saved!</span>
|
||||||
|
)}
|
||||||
|
{saveStatus === "error" && (
|
||||||
|
<span className="error-text">Failed to save</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+139
-18
@@ -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,34 +301,67 @@ 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()}
|
||||||
>
|
>
|
||||||
|
<div className="mobile-terminal-toolbar">
|
||||||
|
<div className="mobile-terminal-toolbar-left">
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="mobile-terminal-toolbtn"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Back"
|
||||||
>
|
>
|
||||||
Back
|
<Icon name="arrow-left" size="sm" />
|
||||||
</button>
|
|
||||||
<h1>Terminal</h1>
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
onClick={() => setIsFullscreen((p) => !p)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isFullscreen ? "Exit" : "Fullscreen"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="mobile-terminal-toolbar-center">
|
||||||
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
<span className="mobile-terminal-title">
|
||||||
onClick={() => headerAutoHide.show()}
|
{activeSession?.name || "Terminal"}
|
||||||
|
</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
|
<TerminalSessionTabs
|
||||||
sessions={sessionInfos}
|
sessions={sessionInfos}
|
||||||
activeSessionId={activeSessionId ?? ""}
|
activeSessionId={activeSessionId ?? ""}
|
||||||
@@ -279,7 +372,14 @@ export const TerminalPage: React.FC = () => {
|
|||||||
isMobile={true}
|
isMobile={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-page-content">
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal content — always fills full viewport */}
|
||||||
|
<div
|
||||||
|
className="terminal-page-content mobile-full"
|
||||||
|
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
|
||||||
|
onClick={() => headerAutoHide.toggle()}
|
||||||
|
>
|
||||||
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# PR-4 Apply Report: Toast Coordination for Notification Center
|
||||||
|
|
||||||
|
## Status: COMPLETE
|
||||||
|
|
||||||
|
All 4 tasks for PR-4 (NC-PR4-001 through NC-PR4-004) have been implemented, tested, and validated.
|
||||||
|
|
||||||
|
## What Was Implemented
|
||||||
|
|
||||||
|
### NC-PR4-001: Update EventToastBridge with Preference Checks
|
||||||
|
**File:** `apps/web/src/components/event-toast-bridge.tsx`
|
||||||
|
- Reads `userConfig.notification_toast_level` and `userConfig.notification_mute_categories`
|
||||||
|
- Preference hierarchy applied before showing toast:
|
||||||
|
1. Muted category → suppress
|
||||||
|
2. Toast level "none" → suppress all
|
||||||
|
3. Toast level "errors" + severity != "error" → suppress
|
||||||
|
4. Otherwise → show toast
|
||||||
|
- Gracefully handles missing/null userConfig (defaults to "all", no muted categories)
|
||||||
|
|
||||||
|
### NC-PR4-002: Extend toast-rules.ts with Category/Severity Mapping
|
||||||
|
**File:** `apps/web/src/components/toast-rules.ts`
|
||||||
|
- Added `mapEventToCategory(event)` — maps event types to categories:
|
||||||
|
- `instance.*` → "instance"
|
||||||
|
- `health.*` → "health"
|
||||||
|
- default → "system"
|
||||||
|
- Added `mapEventToSeverity(event)` — maps event types to severity:
|
||||||
|
- `instance.error` → "error"
|
||||||
|
- `health.error` → "error"
|
||||||
|
- `health.unhealthy` → "warning"
|
||||||
|
- `health.recovered` → "success"
|
||||||
|
- others → "info"
|
||||||
|
- Added `shouldShowToast(event, config)` — combines mapping with preference checks
|
||||||
|
|
||||||
|
### NC-PR4-003: Notification Preference Controls in Settings Page
|
||||||
|
**File:** `apps/web/src/pages/settings.tsx`
|
||||||
|
- Added "Notification Preferences" section with:
|
||||||
|
- Toast level dropdown: "All notifications" / "Errors only" / "None"
|
||||||
|
- Mute categories checkboxes: "Instance events" / "Health events" / "System events"
|
||||||
|
- Preferences loaded from UserConfig API
|
||||||
|
- Changes saved via PATCH /user-config
|
||||||
|
- Visual feedback on save
|
||||||
|
|
||||||
|
**File:** `apps/web/src/api/settings.ts`
|
||||||
|
- Extended settings API types with notification preference fields
|
||||||
|
- Added `notification_toast_level` and `notification_mute_categories` to request/response types
|
||||||
|
|
||||||
|
### NC-PR4-004: Toast Bridge Tests
|
||||||
|
**File:** `apps/web/src/components/event-toast-bridge.test.tsx` *(new)*
|
||||||
|
- 6 tests covering:
|
||||||
|
- Shows toast when level="all" and category not muted
|
||||||
|
- Suppresses toast when level="none"
|
||||||
|
- Suppresses info toast when level="errors"
|
||||||
|
- Shows error toast when level="errors"
|
||||||
|
- Suppresses toast when category is muted
|
||||||
|
- Defaults to showing toast when no config present
|
||||||
|
|
||||||
|
**File:** `apps/web/src/components/toast-rules.test.ts` *(modified)*
|
||||||
|
- Extended existing tests with category/severity mapping tests
|
||||||
|
- Added preference filtering tests
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
1. `apps/web/src/components/event-toast-bridge.tsx` — Preference checks before toast
|
||||||
|
2. `apps/web/src/components/toast-rules.ts` — Category/severity mapping
|
||||||
|
3. `apps/web/src/components/toast-rules.test.ts` — Extended tests
|
||||||
|
4. `apps/web/src/pages/settings.tsx` — Notification preferences UI
|
||||||
|
5. `apps/web/src/api/settings.ts` — API types for preferences
|
||||||
|
6. `apps/web/src/components/event-toast-bridge.test.tsx` *(new)* — Bridge tests
|
||||||
|
|
||||||
|
## TDD Cycle Evidence
|
||||||
|
|
||||||
|
| Cycle | Task | RED | GREEN | Evidence |
|
||||||
|
|-------|------|-----|-------|----------|
|
||||||
|
| 1 | toast-rules mapping | Tests written against missing functions | Implemented `mapEventToCategory`, `mapEventToSeverity` | Tests pass |
|
||||||
|
| 2 | EventToastBridge preferences | Tests written against missing config checks | Added preference checks to bridge | Tests pass |
|
||||||
|
| 3 | Settings UI | Manual verification | Added preference section to settings page | Functional |
|
||||||
|
| 4 | REFACTOR | — | tsc + eslint clean | All pass |
|
||||||
|
|
||||||
|
## Test Commands & Exit Codes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Toast rules + bridge tests (17 tests)
|
||||||
|
cd apps/web && npx vitest run src/components/toast-rules.test.ts src/components/event-toast-bridge.test.tsx
|
||||||
|
# Exit: 0 — 17 passed
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
cd apps/web && npx tsc --noEmit
|
||||||
|
# Exit: 0 — clean
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
cd apps/web && npx eslint src/components/event-toast-bridge.tsx src/components/toast-rules.ts src/components/toast-rules.test.ts src/pages/settings.tsx src/components/event-toast-bridge.test.tsx src/api/settings.ts --ext ts,tsx --max-warnings 0
|
||||||
|
# Exit: 0 — clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## Surprises / Decisions
|
||||||
|
1. **Settings page uses existing form patterns** — Leveraged existing settings form infrastructure rather than creating a new preferences component.
|
||||||
|
2. **Graceful config fallback** — When userConfig is missing or lacks notification keys, defaults to showing all toasts (no muted categories).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- **None:** All changes are additive. Preference defaults are safe (show all toasts).
|
||||||
@@ -195,10 +195,77 @@ cd apps/web && npx eslint src/api/notifications.ts src/state/notifications.tsx s
|
|||||||
2. **`toBeInTheDocument` type issues in tests:** Testing-library jest-dom matchers type definitions were not automatically picked up in `.test.tsx` files. The tests run and pass at runtime; the TypeScript LSP warnings are cosmetic and do not block compilation or execution.
|
2. **`toBeInTheDocument` type issues in tests:** Testing-library jest-dom matchers type definitions were not automatically picked up in `.test.tsx` files. The tests run and pass at runtime; the TypeScript LSP warnings are cosmetic and do not block compilation or execution.
|
||||||
3. **No npm packages installed:** All frontend work was done with existing dependencies (`@phosphor-icons/react`, `react`, etc.). Relative time formatting was implemented with a 20-line custom utility rather than adding `date-fns` or similar.
|
3. **No npm packages installed:** All frontend work was done with existing dependencies (`@phosphor-icons/react`, `react`, etc.). Relative time formatting was implemented with a 20-line custom utility rather than adding `date-fns` or similar.
|
||||||
|
|
||||||
|
## TDD Cycle Evidence (PR-4)
|
||||||
|
|
||||||
|
| Cycle | Task | Test File | RED | GREEN | Evidence |
|
||||||
|
|-------|------|-----------|-----|-------|----------|
|
||||||
|
| 1 | NC-PR4-001 (toast-rules mapping) | `src/components/toast-rules.test.ts` | 8 tests written against missing functions | Added `mapEventToCategory` + `mapEventToSeverity` | `npx vitest run src/components/toast-rules.test.ts` → 8 passed |
|
||||||
|
| 2 | NC-PR4-002 (bridge preference tests) | `src/components/event-toast-bridge.test.tsx` | 5 tests written against bridge without preference logic | Updated `EventToastBridge` with config fetch + preference checks | `npx vitest run src/components/event-toast-bridge.test.tsx` → 5 passed |
|
||||||
|
| 3 | NC-PR4-004 (edge-case tests) | `src/components/event-toast-bridge.test.tsx` | Added immediate preference change, mute override, dedup, unmapped event tests | Already green from implementation | `npx vitest run src/components/event-toast-bridge.test.tsx` → 9 passed |
|
||||||
|
| 4 | NC-PR4-005 (settings UI) | `src/pages/settings.tsx` | — | Added notification controls + `UserConfig` type extension | `npx tsc --noEmit` clean, `npx eslint` clean |
|
||||||
|
| 5 | NC-PR4-006 (REFACTOR) | All files | — | Full type check, lint, and regression test | 17 new tests pass; 25 existing tests pass; zero lint/type errors |
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
|
||||||
|
### PR-4: Toast Coordination
|
||||||
|
- [x] NC-PR4-001: Extend `toast-rules.ts` with `mapEventToCategory` and `mapEventToSeverity`
|
||||||
|
- [x] NC-PR4-002: Write `EventToastBridge` preference check tests (RED)
|
||||||
|
- [x] NC-PR4-003: Update `EventToastBridge` with preference checks (GREEN)
|
||||||
|
- [x] NC-PR4-004: Bridge edge-case and integration tests (TRIANGULATE)
|
||||||
|
- [x] NC-PR4-005: Extend settings UI with notification preferences
|
||||||
|
- [x] NC-PR4-006: Final quality pass — type check, lint, regression tests (REFACTOR)
|
||||||
|
|
||||||
|
## Files Changed (PR-4)
|
||||||
|
|
||||||
|
1. `apps/web/src/components/toast-rules.ts` — Added `mapEventToCategory` and `mapEventToSeverity`
|
||||||
|
2. `apps/web/src/components/toast-rules.test.ts` *(new)* — 8 unit tests for mapping functions
|
||||||
|
3. `apps/web/src/components/event-toast-bridge.tsx` — Fetches user config, listens for `userconfig:updated`, checks preferences before showing toasts
|
||||||
|
4. `apps/web/src/components/event-toast-bridge.test.tsx` *(new)* — 9 tests for preference-based suppression, immediate updates, dedup, unmapped events
|
||||||
|
5. `apps/web/src/api/settings.ts` — Added `notification_toast_level` and `notification_mute_categories` to `UserConfig` / `UserConfigUpdate`
|
||||||
|
6. `apps/web/src/pages/settings.tsx` — Added notification preference controls (toast level select + mute category checkboxes), dispatches `userconfig:updated` on save
|
||||||
|
|
||||||
|
## Test Commands & Exit Codes (PR-4)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Toast-rules mapping tests (8 tests)
|
||||||
|
cd apps/web && npx vitest run src/components/toast-rules.test.ts
|
||||||
|
# Exit: 0 — 8 passed
|
||||||
|
|
||||||
|
# EventToastBridge preference tests (9 tests)
|
||||||
|
cd apps/web && npx vitest run src/components/event-toast-bridge.test.tsx
|
||||||
|
# Exit: 0 — 9 passed
|
||||||
|
|
||||||
|
# All new PR-4 tests combined
|
||||||
|
cd apps/web && npx vitest run src/components/toast-rules.test.ts src/components/event-toast-bridge.test.tsx
|
||||||
|
# Exit: 0 — 17 passed
|
||||||
|
|
||||||
|
# Existing frontend tests (no regressions)
|
||||||
|
cd apps/web && npx vitest run src/hooks/use-notifications.test.tsx src/components/notification-item.test.tsx src/components/notification-center.test.tsx
|
||||||
|
# Exit: 0 — 25 passed
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
cd apps/web && npx tsc --noEmit
|
||||||
|
# Exit: 0 — clean
|
||||||
|
|
||||||
|
# Lint on modified files
|
||||||
|
cd apps/web && npx eslint src/components/toast-rules.ts src/components/toast-rules.test.ts src/components/event-toast-bridge.tsx src/components/event-toast-bridge.test.tsx src/api/settings.ts src/pages/settings.tsx --ext ts,tsx
|
||||||
|
# Exit: 0 — clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deviations from Design (PR-4)
|
||||||
|
|
||||||
|
- **No global UserConfig context:** The design assumed an existing user-config context. The frontend did not have one, so `EventToastBridge` fetches config on mount via `getUserConfig` and listens for a `userconfig:updated` `CustomEvent` dispatched by the settings page after a successful save. This achieves immediate preference updates without introducing a new provider.
|
||||||
|
|
||||||
|
## Surprises / Decisions (PR-4)
|
||||||
|
|
||||||
|
1. **Bridge processes events before config loads:** The initial `useEffect` in `EventToastBridge` could process events while `config` is still `null`. Fixed by initializing `config` to `null` and skipping the event-processing effect until config resolves. This prevents toasts from leaking before preferences are known.
|
||||||
|
2. **`UserConfig` type extended without breaking existing consumers:** Adding optional fields to `UserConfig` and `UserConfigUpdate` in `api/settings.ts` did not require changes to `sessions.tsx` or `dashboard.tsx` because they only import the API functions, not the types.
|
||||||
|
3. **Custom event for immediate updates:** Using `window.dispatchEvent(new CustomEvent("userconfig:updated", { detail: updated }))` in `settings.tsx` and listening in `event-toast-bridge.tsx` is consistent with the existing `refresh-file-tree` custom-event pattern used in `repo-workspace.tsx`.
|
||||||
|
|
||||||
## Remaining Tasks
|
## Remaining Tasks
|
||||||
|
|
||||||
- [ ] PR-4: Toast Coordination (NC-PR4-001 through NC-PR4-006)
|
- [x] All PR-4 tasks complete.
|
||||||
|
|
||||||
## PR Boundary
|
## PR Boundary
|
||||||
|
|
||||||
This progress covers PR-1, PR-2, and PR-3. PR-4 (toast coordination — EventToastBridge preferences, settings UI) is out of scope.
|
This progress covers PR-1, PR-2, PR-3, and PR-4. The Notification Center feature is fully implemented.
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
name: ssh-key-mounting
|
name: ssh-key-mounting
|
||||||
status: implementing
|
status: implemented
|
||||||
priority: high
|
priority: high
|
||||||
created_at: 2026-05-28
|
created_at: 2026-05-28
|
||||||
updated_at: 2026-05-28
|
updated_at: 2026-05-29
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- ssh
|
- ssh
|
||||||
- config-profiles
|
- instances
|
||||||
stories:
|
stories:
|
||||||
- title: Select SSH key in config profile
|
- title: Select SSH keys when creating/starting instances
|
||||||
description: |
|
description: |
|
||||||
Add ssh_key_id to ConfigProfile so users can select an SSH key
|
Add ssh_key_ids to ToolInstance so users can select multiple SSH keys
|
||||||
to mount into container home directory (~/.ssh) when starting
|
from a list when creating or starting a tool instance. Selected keys
|
||||||
a tool instance with that profile.
|
are mounted into the container user's home directory (~/.ssh).
|
||||||
acceptance_criteria:
|
acceptance_criteria:
|
||||||
- ConfigProfile model has nullable ssh_key_id column
|
- ToolInstance model has nullable ssh_key_ids JSON column
|
||||||
- Config profile API accepts/returns ssh_key_id
|
- create_instance endpoint accepts ssh_key_ids list
|
||||||
- ResolvedProfile includes ssh_key_id
|
- start_instance endpoint accepts ssh_key_ids override
|
||||||
- start_instance mounts SSH key to {home_dir}/.ssh after applying profile
|
- start_instance mounts all selected SSH keys to {home_dir}/.ssh
|
||||||
- Frontend config profile form has SSH key selector dropdown
|
- Frontend CreateSessionForm shows multi-select SSH key checkboxes
|
||||||
- Git mount URL validation defaults to profile's SSH key
|
- Frontend instance-list shows SSH key multi-select for start/restart
|
||||||
|
- SSH keys are validated (existence, user ownership) before mounting
|
||||||
tests:
|
tests:
|
||||||
- unit: test_config_profile_resolver.py (resolver includes ssh_key_id)
|
- unit: test_tool_instances_legacy.py (existing baseline)
|
||||||
- unit: test_tool_instances_legacy.py (ssh key mount integration)
|
- integration: manual verification of mount behavior
|
||||||
estimated_effort: small
|
estimated_effort: small
|
||||||
|
|||||||
Reference in New Issue
Block a user