Compare commits
29 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 |
@@ -4,6 +4,10 @@
|
||||
|
||||
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
|
||||
|
||||
1. Current user instruction
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""fix code-server bind-addr to host in DB template
|
||||
|
||||
Revision ID: 2026_05_29_fix_code_server_bind_addr
|
||||
Revises: 2026_05_29_fix_web_tool_bind_address
|
||||
Create Date: 2026-05-29 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_fix_code_server_bind_addr"
|
||||
down_revision: str | None = "2026_05_29_fix_web_tool_bind_address"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Find code-server tool types with broken --bind-addr in compose template
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_template
|
||||
FROM tool_types
|
||||
WHERE name = 'code-server'
|
||||
AND compose_template LIKE '%--bind-addr%'
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
for tool_id, compose_template in result:
|
||||
updated = compose_template.replace(
|
||||
"--bind-addr 0.0.0.0:8443", "--host 0.0.0.0"
|
||||
).replace("--bind-addr", "--host 0.0.0.0")
|
||||
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE tool_types
|
||||
SET compose_template = :compose_template
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"compose_template": updated, "id": tool_id},
|
||||
)
|
||||
|
||||
print(
|
||||
f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Fix code-server bind address to include port
|
||||
|
||||
Revision ID: 2026_05_29_fix_code_server_bind_addr_port
|
||||
Revises: 2026_05_29_remove_lsio_command_override
|
||||
Create Date: 2026-05-29 18:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import yaml
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_fix_code_server_bind_addr_port"
|
||||
down_revision: Union[str, None] = "2026_05_29_remove_lsio_command_override"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _fix_tool_type_templates(conn) -> None:
|
||||
"""Fix code-server tool type templates with broken --host override."""
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_template, default_port
|
||||
FROM tool_types
|
||||
WHERE name = 'code-server'
|
||||
AND compose_template LIKE '%--host%'
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
for tool_id, compose_template, default_port in result:
|
||||
port = default_port or 8443
|
||||
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||
|
||||
# Replace any line containing --host with the correct bind-addr
|
||||
lines = compose_template.split("\n")
|
||||
new_lines = []
|
||||
modified = False
|
||||
for line in lines:
|
||||
if "command:" in line and "--host" in line:
|
||||
indent = line[: len(line) - len(line.lstrip())]
|
||||
new_lines.append(f"{indent}command: {expected}")
|
||||
modified = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not modified:
|
||||
continue
|
||||
|
||||
updated = "\n".join(new_lines)
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE tool_types
|
||||
SET compose_template = :compose_template
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"compose_template": updated, "id": tool_id},
|
||||
)
|
||||
print(f"Fixed code-server template ({tool_id}): replaced --host with {expected}")
|
||||
|
||||
|
||||
def _fix_instance_compose_files(conn) -> None:
|
||||
"""Fix existing instance compose files on disk with broken --host override."""
|
||||
from pathlib import Path
|
||||
|
||||
# Use information_schema to check if compose_path column exists
|
||||
col_result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tool_instances'
|
||||
AND column_name = 'compose_path'
|
||||
""")
|
||||
).fetchone()
|
||||
|
||||
if not col_result:
|
||||
print("compose_path column not found, skipping instance file fixes")
|
||||
return
|
||||
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_path, tool_type_id
|
||||
FROM tool_instances
|
||||
WHERE compose_path IS NOT NULL
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
for instance_id, compose_path, tool_type_id in result:
|
||||
path = Path(compose_path)
|
||||
if not path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
content = path.read_text()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if "--host" not in content:
|
||||
continue
|
||||
|
||||
# Get default_port from tool_type
|
||||
port_result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT default_port FROM tool_types WHERE id = :id
|
||||
"""),
|
||||
{"id": tool_type_id},
|
||||
).fetchone()
|
||||
port = port_result[0] if port_result and port_result[0] else 8443
|
||||
expected = f"--bind-addr 0.0.0.0:{port}"
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(content)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not data or "services" not in data:
|
||||
continue
|
||||
|
||||
modified = False
|
||||
for svc in data["services"].values():
|
||||
if "command" in svc:
|
||||
cmd = svc["command"]
|
||||
if "--host" in cmd:
|
||||
svc["command"] = expected
|
||||
modified = True
|
||||
|
||||
if not modified:
|
||||
continue
|
||||
|
||||
try:
|
||||
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||
print(
|
||||
f"Fixed code-server instance compose ({instance_id}): "
|
||||
f"replaced --host with {expected}"
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Failed to fix instance {instance_id}: {exc}")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
_fix_tool_type_templates(conn)
|
||||
_fix_instance_compose_files(conn)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Remove broken command override from LSIO code-server templates
|
||||
|
||||
Revision ID: 2026_05_29_remove_lsio_command_override
|
||||
Revises: 2026_05_29_fix_code_server_bind_addr
|
||||
Create Date: 2026-05-29 15:05:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "2026_05_29_remove_lsio_command_override"
|
||||
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Fix tool_types templates in DB
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_template
|
||||
FROM tool_types
|
||||
WHERE name = 'code-server'
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
for tool_id, compose_template in result:
|
||||
try:
|
||||
data = yaml.safe_load(compose_template)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not data or "services" not in data:
|
||||
continue
|
||||
|
||||
modified = False
|
||||
for svc in data["services"].values():
|
||||
image = svc.get("image", "")
|
||||
if not image or "linuxserver" not in image:
|
||||
continue
|
||||
if "command" in svc:
|
||||
cmd = svc["command"]
|
||||
if "--bind-addr" in cmd or "--host" in cmd:
|
||||
del svc["command"]
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
updated = yaml.dump(data, default_flow_style=False)
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE tool_types
|
||||
SET compose_template = :compose_template
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"compose_template": updated, "id": tool_id},
|
||||
)
|
||||
print(f"Removed broken command override from LSIO template ({tool_id})")
|
||||
|
||||
# Fix existing instance compose files on disk
|
||||
# Use information_schema to check if compose_path column exists
|
||||
col_result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tool_instances'
|
||||
AND column_name = 'compose_path'
|
||||
""")
|
||||
).fetchone()
|
||||
|
||||
if col_result:
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT id, compose_path
|
||||
FROM tool_instances
|
||||
WHERE compose_path IS NOT NULL
|
||||
""")
|
||||
).fetchall()
|
||||
|
||||
for instance_id, compose_path in result:
|
||||
path = Path(compose_path)
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
content = path.read_text()
|
||||
data = yaml.safe_load(content)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not data or "services" not in data:
|
||||
continue
|
||||
|
||||
modified = False
|
||||
for svc in data["services"].values():
|
||||
image = svc.get("image", "")
|
||||
if not image or "linuxserver" not in image:
|
||||
continue
|
||||
if "command" in svc:
|
||||
cmd = svc["command"]
|
||||
if "--bind-addr" in cmd or "--host" in cmd:
|
||||
del svc["command"]
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
path.write_text(yaml.dump(data, default_flow_style=False))
|
||||
print(
|
||||
f"Removed broken command override from instance compose "
|
||||
f"({instance_id})"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -52,7 +52,6 @@ from src.services.docker import (
|
||||
find_free_port,
|
||||
get_container_id,
|
||||
get_container_logs,
|
||||
get_container_name,
|
||||
get_container_status,
|
||||
recreate_tunnel,
|
||||
render_compose_template,
|
||||
@@ -618,7 +617,44 @@ def _modify_compose_file(
|
||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||
|
||||
|
||||
def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
|
||||
def _ensure_container_name_in_compose(compose_path: str, container_name: str) -> None:
|
||||
"""Ensure compose file has explicit container_name for predictable naming.
|
||||
|
||||
Docker Compose auto-generates container names from the project directory
|
||||
when container_name is absent. This breaks tunnel connectivity because
|
||||
get_container_name(instance.name) cannot find the container. We inject
|
||||
container_name into every service so the container has a predictable name.
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
compose_file = Path(compose_path)
|
||||
if not compose_file.exists():
|
||||
return
|
||||
|
||||
content = compose_file.read_text()
|
||||
compose_data = yaml.safe_load(content)
|
||||
|
||||
if not compose_data or "services" not in compose_data:
|
||||
return
|
||||
|
||||
modified = False
|
||||
for svc_name, svc_config in compose_data["services"].items():
|
||||
if "container_name" not in svc_config:
|
||||
svc_config["container_name"] = container_name.lower()
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||
logger.info(
|
||||
"Injected container_name '%s' into compose file",
|
||||
container_name.lower(),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_web_bind_address(
|
||||
compose_path: str, tool_type_name: str, default_port: int
|
||||
) -> None:
|
||||
"""Auto-inject bind address for known web tools that default to 127.0.0.1.
|
||||
|
||||
Many web tools (code-server, jupyter) bind to localhost by default,
|
||||
@@ -628,9 +664,12 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
KNOWN_BIND_FIXES = {
|
||||
"code-server": "--host 0.0.0.0",
|
||||
"jupyter-notebook": "start-notebook.sh --ip=0.0.0.0",
|
||||
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)
|
||||
@@ -648,28 +687,65 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
|
||||
return
|
||||
|
||||
for service_config in compose_data["services"].values():
|
||||
# Skip if command is already overridden
|
||||
if "command" in service_config:
|
||||
return
|
||||
|
||||
image = service_config.get("image", "")
|
||||
if not image:
|
||||
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
|
||||
if tool_type_name == "code-server" and (
|
||||
is_code_server = tool_type_name == "code-server" and (
|
||||
"code-server" in image or "coder" in image
|
||||
):
|
||||
service_config["command"] = bind_command
|
||||
break
|
||||
if tool_type_name == "jupyter-notebook" and (
|
||||
)
|
||||
is_jupyter = tool_type_name == "jupyter-notebook" and (
|
||||
"jupyter" in image or "notebook" in image
|
||||
):
|
||||
service_config["command"] = bind_command
|
||||
break
|
||||
)
|
||||
if not is_code_server and not is_jupyter:
|
||||
continue
|
||||
|
||||
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
|
||||
logger.info("Injected bind address for %s: %s", tool_type_name, bind_command)
|
||||
existing_command = service_config.get("command", "")
|
||||
if existing_command:
|
||||
# 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(
|
||||
@@ -1606,7 +1682,12 @@ async def start_instance(
|
||||
|
||||
# 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)
|
||||
_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
|
||||
logger.debug(
|
||||
@@ -1634,24 +1715,23 @@ async def start_instance(
|
||||
detail=f"failed to start instance: {stderr}",
|
||||
)
|
||||
|
||||
# Get container ID and name
|
||||
container_id = get_container_id(instance.name)
|
||||
# Get container ID and name (use predictable name from compose)
|
||||
expected_container_name = instance.name.lower()
|
||||
container_id = get_container_id(expected_container_name)
|
||||
if container_id:
|
||||
instance.container_id = container_id
|
||||
logger.debug("Container ID for instance %s: %s", instance.id, container_id)
|
||||
|
||||
container_name = get_container_name(instance.name)
|
||||
if container_name:
|
||||
instance.container_name = container_name
|
||||
logger.debug("Container name for instance %s: %s", instance.id, container_name)
|
||||
instance.container_name = expected_container_name
|
||||
logger.debug("Container name for instance %s: %s", instance.id, expected_container_name)
|
||||
|
||||
# Connect container to backend network so API can reach it
|
||||
logger.debug("Connecting container %s to backend network...", container_name)
|
||||
connected = connect_container_to_network(container_name, "backend")
|
||||
if connected:
|
||||
logger.debug("Successfully connected %s to backend network", container_name)
|
||||
else:
|
||||
logger.warning("Failed to connect %s to backend network", container_name)
|
||||
# Connect container to backend network so API can reach it
|
||||
logger.debug("Connecting container %s to backend network...", expected_container_name)
|
||||
connected = connect_container_to_network(expected_container_name, "backend")
|
||||
if connected:
|
||||
logger.debug("Successfully connected %s to backend network", expected_container_name)
|
||||
else:
|
||||
logger.warning("Failed to connect %s to backend network", expected_container_name)
|
||||
|
||||
# Verify container reached running state
|
||||
if instance.container_id:
|
||||
@@ -2078,6 +2158,15 @@ async def restart_instance(
|
||||
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(
|
||||
instance.compose_path, "restart"
|
||||
)
|
||||
@@ -2107,7 +2196,7 @@ async def restart_instance(
|
||||
# Create new temporary tunnel
|
||||
try:
|
||||
tunnel_info = start_cloudflared_tunnel(
|
||||
container_name=instance.container_name or instance.name,
|
||||
container_name=instance.name.lower(),
|
||||
port=instance_port,
|
||||
)
|
||||
instance.tunnel_id = tunnel_info["pid"]
|
||||
|
||||
@@ -159,7 +159,7 @@ def execute_compose_command(
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
if action == "up":
|
||||
cmd.extend(["up", "-d"])
|
||||
cmd.extend(["up", "-d", "--force-recreate"])
|
||||
elif action == "down":
|
||||
cmd.extend(["down", "-v"])
|
||||
elif action in ("start", "stop", "restart"):
|
||||
|
||||
@@ -149,8 +149,8 @@ async def test_lifecycle_running_creates_notification(
|
||||
assert len(notifications) == 1
|
||||
n = notifications[0]
|
||||
assert n.category == "instance"
|
||||
assert n.severity == "info"
|
||||
assert n.title == "Health Changed"
|
||||
assert n.severity == "success"
|
||||
assert n.title == "Container ready"
|
||||
assert n.source_type == "tool_instances"
|
||||
assert n.source_id == test_instance.id
|
||||
|
||||
@@ -315,9 +315,9 @@ async def test_notification_ownership_matches_instance_owner(
|
||||
event_bus=event_bus,
|
||||
session=db_session,
|
||||
instance=instance,
|
||||
event_type="instance.created",
|
||||
status="pending",
|
||||
message="Instance created",
|
||||
event_type="instance.health_changed",
|
||||
status="running",
|
||||
message="Container running",
|
||||
)
|
||||
|
||||
result = await db_session.execute(
|
||||
|
||||
@@ -184,7 +184,7 @@ class TestToolTypesAPIExtended:
|
||||
"interfaces": ["web", "terminal"],
|
||||
"default_port": 8443,
|
||||
"definition_type": "compose",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --host 0.0.0.0\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
|
||||
"readiness_probe": {
|
||||
"command": "curl -f http://localhost:8443",
|
||||
"timeout": 30,
|
||||
|
||||
@@ -411,8 +411,9 @@ class TestStartInstanceLegacyFallback:
|
||||
@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.get_container_name")
|
||||
@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._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -423,8 +424,9 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -440,7 +442,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -509,8 +510,9 @@ class TestStartInstanceLegacyFallback:
|
||||
@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.get_container_name")
|
||||
@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._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -521,8 +523,9 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -538,7 +541,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -606,8 +608,9 @@ class TestStartInstanceLegacyFallback:
|
||||
@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.get_container_name")
|
||||
@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._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances._get_user")
|
||||
@@ -618,8 +621,9 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_user,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -635,7 +639,6 @@ class TestStartInstanceLegacyFallback:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -705,12 +708,14 @@ 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.get_container_name")
|
||||
@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")
|
||||
@@ -719,12 +724,14 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_apply_ssh,
|
||||
mock_prepare_ssh,
|
||||
mock_write_compose,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
@@ -743,7 +750,6 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -836,12 +842,14 @@ class TestStartInstanceSshPermissions:
|
||||
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.get_container_name")
|
||||
@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")
|
||||
@@ -850,12 +858,14 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project,
|
||||
mock_get_user,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
mock_apply_ssh,
|
||||
mock_prepare_ssh,
|
||||
mock_session,
|
||||
fake_user_id,
|
||||
fake_project_id,
|
||||
@@ -870,7 +880,6 @@ class TestStartInstanceSshPermissions:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
@@ -933,14 +942,15 @@ class TestStartInstanceSshPermissions:
|
||||
mock_session.get.side_effect = _get
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
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,
|
||||
)
|
||||
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")
|
||||
@@ -952,8 +962,9 @@ class TestStartInstanceManifestBranch:
|
||||
@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.get_container_name")
|
||||
@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._prepare_manifest_instance")
|
||||
@patch("src.api.tool_instances.write_compose_file")
|
||||
@@ -966,8 +977,9 @@ class TestStartInstanceManifestBranch:
|
||||
mock_write_compose,
|
||||
mock_prepare_manifest,
|
||||
mock_sanitize,
|
||||
mock_ensure_web_bind,
|
||||
mock_ensure_container_name,
|
||||
mock_connect_network,
|
||||
mock_get_container_name,
|
||||
mock_get_container_id,
|
||||
mock_execute_compose,
|
||||
mock_wait_container,
|
||||
@@ -987,7 +999,6 @@ class TestStartInstanceManifestBranch:
|
||||
mock_get_project.return_value = AsyncMock()
|
||||
mock_execute_compose.return_value = (0, "started", "")
|
||||
mock_get_container_id.return_value = "abc123"
|
||||
mock_get_container_name.return_value = "test-container"
|
||||
mock_connect_network.return_value = True
|
||||
mock_wait_container.return_value = {
|
||||
"success": True,
|
||||
|
||||
@@ -313,6 +313,96 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
term.focus();
|
||||
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)
|
||||
let fitAttempts = 0;
|
||||
const doInitialFit = () => {
|
||||
@@ -440,6 +530,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
if (touchCleanup) touchCleanup();
|
||||
if (ws) {
|
||||
ws.close(1000, "Component unmounting");
|
||||
}
|
||||
@@ -568,7 +659,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}>
|
||||
<div
|
||||
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
|
||||
>
|
||||
{showControls && (
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-header-left">
|
||||
|
||||
+153
-32
@@ -5,10 +5,15 @@ import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "../components/terminal-session-tabs";
|
||||
import { Icon } from "../components/icon";
|
||||
import { SpecialKeysStrip } from "../components/special-keys-strip";
|
||||
import { SpecialKeysPanel } from "../components/special-keys-panel";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
||||
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
|
||||
import type { TerminalSession } from "../api/terminal";
|
||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||
sessions.map((s) => ({
|
||||
@@ -39,7 +44,15 @@ export const TerminalPage: React.FC = () => {
|
||||
Record<string, TerminalStatus>
|
||||
>({});
|
||||
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
||||
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
||||
const focusInputRef = useRef<(() => void) | null>(null);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
||||
null,
|
||||
);
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -162,6 +175,47 @@ export const TerminalPage: React.FC = () => {
|
||||
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
|
||||
const handleFullscreenClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
@@ -205,15 +259,17 @@ export const TerminalPage: React.FC = () => {
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
(
|
||||
_sendData: (data: string) => void,
|
||||
sendData: (data: string) => void,
|
||||
status: TerminalStatus,
|
||||
_focusInput: () => void,
|
||||
focusInput: () => void,
|
||||
changeFontSize: (delta: number) => void,
|
||||
) => {
|
||||
setTerminalStatuses((prev) => ({
|
||||
...prev,
|
||||
[activeSessionId ?? "default"]: status,
|
||||
}));
|
||||
sendDataRef.current = sendData;
|
||||
focusInputRef.current = focusInput;
|
||||
changeFontSizeRef.current = changeFontSize;
|
||||
},
|
||||
[activeSessionId],
|
||||
@@ -223,6 +279,10 @@ export const TerminalPage: React.FC = () => {
|
||||
changeFontSizeRef.current?.(delta);
|
||||
}, []);
|
||||
|
||||
const handleSendKey = useCallback((data: string) => {
|
||||
sendDataRef.current?.(data);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||
terminalRefs.current[activeSessionId].current?.reset();
|
||||
@@ -241,45 +301,85 @@ export const TerminalPage: React.FC = () => {
|
||||
const sessionInfos = SESSIONS_TO_INFO(sessions);
|
||||
|
||||
if (isMobile) {
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId);
|
||||
const status =
|
||||
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
|
||||
>
|
||||
{/* Overlay status bar — floats over terminal, never resizes it */}
|
||||
<div
|
||||
className={`terminal-page-header mobile-header ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
||||
onClick={() => headerAutoHide.show()}
|
||||
className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setIsFullscreen((p) => !p)}
|
||||
type="button"
|
||||
>
|
||||
{isFullscreen ? "Exit" : "Fullscreen"}
|
||||
</button>
|
||||
<div className="mobile-terminal-toolbar">
|
||||
<div className="mobile-terminal-toolbar-left">
|
||||
<button
|
||||
className="mobile-terminal-toolbtn"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
aria-label="Back"
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mobile-terminal-toolbar-center">
|
||||
<span className="mobile-terminal-title">
|
||||
{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
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content — always fills full viewport */}
|
||||
<div
|
||||
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
|
||||
onClick={() => headerAutoHide.show()}
|
||||
className="terminal-page-content mobile-full"
|
||||
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
|
||||
onClick={() => headerAutoHide.toggle()}
|
||||
>
|
||||
<TerminalSessionTabs
|
||||
sessions={sessionInfos}
|
||||
activeSessionId={activeSessionId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
onClose={handleClose}
|
||||
onCreate={handleCreate}
|
||||
onRename={handleRename}
|
||||
isMobile={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="terminal-page-content">
|
||||
{error && <div className="terminal-error-banner">{error}</div>}
|
||||
{sessions
|
||||
.filter((session) => session.id === activeSessionId)
|
||||
@@ -291,6 +391,9 @@ export const TerminalPage: React.FC = () => {
|
||||
sessionId={session.id}
|
||||
onClose={() => handleClose(session.id)}
|
||||
isMobile={true}
|
||||
showControls={false}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
@@ -301,6 +404,24 @@ export const TerminalPage: React.FC = () => {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+128
-16
@@ -77,6 +77,11 @@ body {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
html.terminal-page-open,
|
||||
body.terminal-page-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-theme="dark"] body {
|
||||
background: radial-gradient(circle at top right, #2a2520, var(--bg));
|
||||
}
|
||||
@@ -2950,42 +2955,148 @@ a.nav-item,
|
||||
background: #cd3131;
|
||||
}
|
||||
|
||||
/* Mobile auto-hide header and tabs */
|
||||
.terminal-page.mobile .terminal-page-header,
|
||||
.mobile-tabs-container {
|
||||
/* ============================================
|
||||
Mobile Terminal Overlay
|
||||
============================================ */
|
||||
|
||||
/* Mobile terminal page — no padding, terminal fills viewport */
|
||||
.terminal-page.mobile {
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Overlay status bar — floats over terminal, never resizes it */
|
||||
.mobile-terminal-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.terminal-page.mobile .terminal-page-header.hidden,
|
||||
.mobile-tabs-container.hidden {
|
||||
.mobile-terminal-overlay.hidden {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.terminal-page.mobile .terminal-page-header.visible,
|
||||
.mobile-tabs-container.visible {
|
||||
.mobile-terminal-overlay.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Toolbar row */
|
||||
.mobile-terminal-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mobile-terminal-toolbar-left,
|
||||
.mobile-terminal-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobile-terminal-toolbar-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-terminal-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #d4d4d4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-terminal-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-terminal-status.connecting {
|
||||
background: #f5f543;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.mobile-terminal-status.connected {
|
||||
background: #0dbc79;
|
||||
}
|
||||
|
||||
.mobile-terminal-status.disconnected,
|
||||
.mobile-terminal-status.error {
|
||||
background: #cd3131;
|
||||
}
|
||||
|
||||
.mobile-terminal-toolbtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #3e3e3e;
|
||||
border-radius: 6px;
|
||||
color: #d4d4d4;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-terminal-toolbtn:hover {
|
||||
background: #3e3e3e;
|
||||
}
|
||||
|
||||
/* Session tabs inside overlay */
|
||||
.mobile-terminal-overlay-tabs {
|
||||
background: #1e1e1e;
|
||||
border-top: 1px solid #3e3e3e;
|
||||
}
|
||||
|
||||
.mobile-terminal-overlay-tabs .terminal-session-tabs {
|
||||
background: #1e1e1e;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Terminal content — always fills full viewport on mobile */
|
||||
.terminal-page-content.mobile-full {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile fullscreen */
|
||||
@media (max-width: 767px) {
|
||||
.terminal-page.fullscreen {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-page.mobile .terminal-page-header {
|
||||
padding: var(--space-2);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.terminal-page.mobile .terminal-page-header h1 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.terminal-session-tab-name {
|
||||
max-width: 80px;
|
||||
}
|
||||
@@ -3597,6 +3708,7 @@ a.nav-item,
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* xterm.js manages its own sizing */
|
||||
|
||||
@@ -13,7 +13,11 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -92,7 +96,7 @@ services:
|
||||
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-}
|
||||
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
|
||||
volumes:
|
||||
- repo_data:/data/repos
|
||||
- /data/repos:/data/repos
|
||||
- /data/instances:/data/instances
|
||||
- avatar_uploads:/app/uploads
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
@@ -116,7 +120,6 @@ services:
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
repo_data:
|
||||
avatar_uploads:
|
||||
|
||||
networks:
|
||||
|
||||
+7
-4
@@ -1,4 +1,4 @@
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
@@ -14,7 +14,11 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -57,7 +61,7 @@ services:
|
||||
REPO_BASE_PATH: /data/repos
|
||||
INSTANCE_BASE_PATH: /data/instances
|
||||
volumes:
|
||||
- repo_data:/data/repos
|
||||
- /data/repos:/data/repos
|
||||
- /data/instances:/data/instances
|
||||
ports:
|
||||
- "8000:8000"
|
||||
@@ -91,7 +95,6 @@ services:
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
repo_data:
|
||||
|
||||
networks:
|
||||
backend:
|
||||
|
||||
Reference in New Issue
Block a user