Bump version to 1.1.2

This commit is contained in:
GitHub Copilot
2026-01-28 16:52:11 +00:00
parent 3bc77d4a85
commit 14234e2531
10 changed files with 75 additions and 19 deletions
-1
View File
@@ -26,5 +26,4 @@ tests
docs docs
examples examples
Dockerfile Dockerfile
README.md
LICENSE LICENSE
+6 -2
View File
@@ -101,8 +101,9 @@ webterm --docker-watch
When a container starts with the label, it automatically appears in the dashboard. When it stops, it's removed. Label values: When a container starts with the label, it automatically appears in the dashboard. When it stops, it's removed. Label values:
- `webterm-command: auto` - Runs `docker exec -it <container> /bin/bash` - `webterm-command: auto` - Runs `docker exec -it <container> /bin/bash` (override with `WEBTERM_DOCKER_AUTO_COMMAND`)
- `webterm-command: <command>` - Runs the specified command - `webterm-command: <command>` - Runs the specified command
- `webterm-theme: <theme>` - Sets the terminal theme for that container (xterm, monokai, dark, light, dracula, catppuccin, nord, gruvbox, solarized, tokyo)
Example docker-compose.yaml: Example docker-compose.yaml:
@@ -112,18 +113,20 @@ services:
image: myapp:latest image: myapp:latest
labels: labels:
webterm-command: auto # Opens bash in container webterm-command: auto # Opens bash in container
webterm-theme: monokai
logs: logs:
image: myapp:latest image: myapp:latest
labels: labels:
webterm-command: docker logs -f myapp # Shows logs webterm-command: docker logs -f myapp # Shows logs
webterm-theme: nord
``` ```
**Requires**: Docker socket access (`-v /var/run/docker.sock:/var/run/docker.sock`) **Requires**: Docker socket access (`-v /var/run/docker.sock:/var/run/docker.sock`)
### Docker Compose Integration ### Docker Compose Integration
Point to a docker-compose file; services with the label `webterm-command` become tiles: Point to a docker-compose file; services with the label `webterm-command` become tiles (and `webterm-theme` applies there too):
```yaml ```yaml
services: services:
@@ -131,6 +134,7 @@ services:
image: postgres image: postgres
labels: labels:
webterm-command: docker exec -it db psql webterm-command: docker exec -it db psql
webterm-theme: gruvbox
``` ```
Start with: Start with:
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "webterm" name = "webterm"
version = "1.1.1" version = "1.1.2"
description = "Serve terminal sessions over the web" description = "Serve terminal sessions over the web"
authors = ["Will McGugan <will@textualize.io>"] authors = ["Will McGugan <will@textualize.io>"]
license = "MIT" license = "MIT"
+3
View File
@@ -25,6 +25,7 @@ class App(BaseModel):
color: str = "" color: str = ""
command: ExpandVarsStr = "" command: ExpandVarsStr = ""
terminal: bool = False terminal: bool = False
theme: str | None = None
class Config(BaseModel): class Config(BaseModel):
@@ -136,6 +137,7 @@ def load_compose_manifest(manifest_path: Path) -> list[App]:
command = _extract_label(labels, "webterm-command") command = _extract_label(labels, "webterm-command")
if not command: if not command:
continue continue
theme = _extract_label(labels, "webterm-theme")
slug = slugify(name) slug = slugify(name)
apps.append( apps.append(
App( App(
@@ -145,6 +147,7 @@ def load_compose_manifest(manifest_path: Path) -> list[App]:
path=service.get("working_dir", "./"), path=service.get("working_dir", "./"),
color="", color="",
terminal=True, terminal=True,
theme=theme,
) )
) )
return apps return apps
+17 -2
View File
@@ -10,6 +10,7 @@ import asyncio
import contextlib import contextlib
import json import json
import logging import logging
import os
from typing import TYPE_CHECKING, Callable from typing import TYPE_CHECKING, Callable
from .docker_stats import get_docker_socket_path from .docker_stats import get_docker_socket_path
@@ -20,9 +21,15 @@ if TYPE_CHECKING:
log = logging.getLogger("webterm") log = logging.getLogger("webterm")
LABEL_NAME = "webterm-command" LABEL_NAME = "webterm-command"
THEME_LABEL = "webterm-theme"
AUTO_COMMAND_ENV = "WEBTERM_DOCKER_AUTO_COMMAND"
DEFAULT_COMMAND = "/bin/bash" DEFAULT_COMMAND = "/bin/bash"
def _get_auto_command() -> str:
return os.environ.get(AUTO_COMMAND_ENV, DEFAULT_COMMAND)
class DockerWatcher: class DockerWatcher:
"""Watch Docker events and manage terminal sessions dynamically.""" """Watch Docker events and manage terminal sessions dynamically."""
@@ -120,9 +127,16 @@ class DockerWatcher:
if label_value.lower() == "auto": if label_value.lower() == "auto":
container_name = self._get_container_name(container) container_name = self._get_container_name(container)
return f"docker exec -it {container_name} {DEFAULT_COMMAND}" return f"docker exec -it {container_name} {_get_auto_command()}"
return label_value return label_value
def _get_container_theme(self, container: dict) -> str | None:
labels = container.get("Labels", {})
value = labels.get(THEME_LABEL)
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _get_container_name(self, container: dict) -> str: def _get_container_name(self, container: dict) -> str:
"""Get container name (without leading /).""" """Get container name (without leading /)."""
names = container.get("Names", []) names = container.get("Names", [])
@@ -139,6 +153,7 @@ class DockerWatcher:
slug = self._container_to_slug(container) slug = self._container_to_slug(container)
name = self._get_container_name(container) name = self._get_container_name(container)
command = self._get_container_command(container) command = self._get_container_command(container)
theme = self._get_container_theme(container)
container_id = container.get("Id", "") container_id = container.get("Id", "")
if slug in self._managed_containers: if slug in self._managed_containers:
@@ -147,7 +162,7 @@ class DockerWatcher:
log.info("Adding container: %s (slug=%s, cmd=%s)", name, slug, command) log.info("Adding container: %s (slug=%s, cmd=%s)", name, slug, command)
self._managed_containers[slug] = container_id self._managed_containers[slug] = container_id
self._session_manager.add_app(name, command, slug, terminal=True) self._session_manager.add_app(name, command, slug, terminal=True, theme=theme)
if self._on_container_added: if self._on_container_added:
self._on_container_added(slug, name, command) self._on_container_added(slug, name, command)
+9 -6
View File
@@ -188,16 +188,18 @@ class LocalServer:
def app_count(self) -> int: def app_count(self) -> int:
return len(self.session_manager.apps) return len(self.session_manager.apps)
def add_app(self, name: str, command: str, slug: str = "") -> None: def add_app(self, name: str, command: str, slug: str = "", theme: str | None = None) -> None:
slug = slug or generate().lower() slug = slug or generate().lower()
self.session_manager.add_app(name, command, slug=slug) self.session_manager.add_app(name, command, slug=slug, theme=theme)
def add_terminal(self, name: str, command: str, slug: str = "") -> None: def add_terminal(
self, name: str, command: str, slug: str = "", theme: str | None = None
) -> None:
if constants.WINDOWS: if constants.WINDOWS:
log.warning("Sorry, webterm does not currently support terminals on Windows") log.warning("Sorry, webterm does not currently support terminals on Windows")
return return
slug = slug or generate().lower() slug = slug or generate().lower()
self.session_manager.add_app(name, command, slug=slug, terminal=True) self.session_manager.add_app(name, command, slug=slug, terminal=True, theme=theme)
async def run(self) -> None: async def run(self) -> None:
try: try:
@@ -968,9 +970,10 @@ class LocalServer:
page_title = available_app.name if available_app else "Webterm" page_title = available_app.name if available_app else "Webterm"
# Build data attributes for terminal configuration # Build data attributes for terminal configuration
theme = available_app.theme or self.theme
data_attrs = ( data_attrs = (
f'data-session-websocket-url="{ws_url}" data-font-size="{self.font_size}" ' f'data-session-websocket-url="{ws_url}" data-font-size="{self.font_size}" '
f'data-scrollback="1000" data-theme="{self.theme}"' f'data-scrollback="1000" data-theme="{theme}"'
) )
font_family = self.font_family or "var(--webterm-mono)" font_family = self.font_family or "var(--webterm-mono)"
# Escape quotes for HTML attribute # Escape quotes for HTML attribute
@@ -978,7 +981,7 @@ class LocalServer:
data_attrs += f' data-font-family="{escaped_font}"' data_attrs += f' data-font-family="{escaped_font}"'
# Get theme background color (fallback to black if unknown theme) # Get theme background color (fallback to black if unknown theme)
theme_bg = THEME_BACKGROUNDS.get(self.theme.lower(), "#000000") theme_bg = THEME_BACKGROUNDS.get(theme.lower(), "#000000")
html_content = f"""<!DOCTYPE html> html_content = f"""<!DOCTYPE html>
<html> <html>
+11 -2
View File
@@ -35,7 +35,14 @@ class SessionManager:
self.sessions: dict[SessionID, Session] = {} self.sessions: dict[SessionID, Session] = {}
self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict() self.routes: TwoWayDict[RouteKey, SessionID] = TwoWayDict()
def add_app(self, name: str, command: str, slug: str, terminal: bool = False) -> None: def add_app(
self,
name: str,
command: str,
slug: str,
terminal: bool = False,
theme: str | None = None,
) -> None:
"""Add a new app """Add a new app
Args: Args:
@@ -44,7 +51,9 @@ class SessionManager:
slug: Slug used in URL, or blank to auto-generate on server. slug: Slug used in URL, or blank to auto-generate on server.
""" """
slug = slug or generate().lower() slug = slug or generate().lower()
new_app = config.App(name=name, slug=slug, path="./", command=command, terminal=terminal) new_app = config.App(
name=name, slug=slug, path="./", command=command, terminal=terminal, theme=theme
)
self.apps.append(new_app) self.apps.append(new_app)
self.apps_by_slug[slug] = new_app self.apps_by_slug[slug] = new_app
+3
View File
@@ -27,6 +27,7 @@ def test_load_compose_manifest_reads_label():
svc1: svc1:
labels: labels:
webterm-command: echo svc1 webterm-command: echo svc1
webterm-theme: nord
svc2: svc2:
labels: labels:
- webterm-command=echo svc2 - webterm-command=echo svc2
@@ -42,3 +43,5 @@ def test_load_compose_manifest_reads_label():
commands = {a.command for a in apps} commands = {a.command for a in apps}
assert slugs == {"svc1", "svc2"} assert slugs == {"svc1", "svc2"}
assert "echo svc1" in commands and "echo svc2" in commands assert "echo svc1" in commands and "echo svc2" in commands
svc1 = next(app for app in apps if app.slug == "svc1")
assert svc1.theme == "nord"
+24 -5
View File
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from webterm.docker_watcher import DEFAULT_COMMAND, LABEL_NAME, DockerWatcher from webterm.docker_watcher import LABEL_NAME, THEME_LABEL, DockerWatcher, _get_auto_command
@pytest.fixture @pytest.fixture
@@ -56,7 +56,7 @@ class TestDockerWatcher:
def test_get_container_command_auto(self, docker_watcher): def test_get_container_command_auto(self, docker_watcher):
"""Test command generation when label is 'auto'.""" """Test command generation when label is 'auto'."""
container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}} container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}}
expected = f"docker exec -it my-container {DEFAULT_COMMAND}" expected = f"docker exec -it my-container {_get_auto_command()}"
assert docker_watcher._get_container_command(container) == expected assert docker_watcher._get_container_command(container) == expected
def test_get_container_command_custom(self, docker_watcher): def test_get_container_command_custom(self, docker_watcher):
@@ -67,13 +67,25 @@ class TestDockerWatcher:
} }
assert docker_watcher._get_container_command(container) == "docker logs -f my-container" assert docker_watcher._get_container_command(container) == "docker logs -f my-container"
def test_get_container_theme(self, docker_watcher):
container = {"Labels": {THEME_LABEL: "nord"}}
assert docker_watcher._get_container_theme(container) == "nord"
def test_get_container_theme_blank(self, docker_watcher):
container = {"Labels": {THEME_LABEL: " "}}
assert docker_watcher._get_container_theme(container) is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_container(self, session_manager): async def test_add_container(self, session_manager):
"""Test adding a container.""" """Test adding a container."""
on_added = MagicMock() on_added = MagicMock()
watcher = DockerWatcher(session_manager, on_container_added=on_added) watcher = DockerWatcher(session_manager, on_container_added=on_added)
container = {"Id": "abc123", "Names": ["/test-container"], "Labels": {LABEL_NAME: "auto"}} container = {
"Id": "abc123",
"Names": ["/test-container"],
"Labels": {LABEL_NAME: "auto", THEME_LABEL: "monokai"},
}
await watcher._add_container(container) await watcher._add_container(container)
@@ -84,6 +96,7 @@ class TestDockerWatcher:
assert "docker exec -it test-container" in call_args[0][1] # command assert "docker exec -it test-container" in call_args[0][1] # command
assert call_args[0][2] == "test-container" # slug assert call_args[0][2] == "test-container" # slug
assert call_args[1]["terminal"] is True assert call_args[1]["terminal"] is True
assert call_args[1]["theme"] == "monokai"
# Should call callback # Should call callback
on_added.assert_called_once_with("test-container", "test-container", call_args[0][1]) on_added.assert_called_once_with("test-container", "test-container", call_args[0][1])
@@ -222,8 +235,8 @@ class TestDockerWatcherIntegration:
("labels", "expected"), ("labels", "expected"),
[ [
({"webterm-command": "echo hi"}, "echo hi"), ({"webterm-command": "echo hi"}, "echo hi"),
({"webterm-command": "auto"}, f"docker exec -it my-container {DEFAULT_COMMAND}"), ({"webterm-command": "auto"}, f"docker exec -it my-container {_get_auto_command()}"),
({"other": "value"}, f"docker exec -it my-container {DEFAULT_COMMAND}"), ({"other": "value"}, f"docker exec -it my-container {_get_auto_command()}"),
], ],
) )
def test_get_container_command_variants(docker_watcher, labels, expected): def test_get_container_command_variants(docker_watcher, labels, expected):
@@ -231,6 +244,12 @@ def test_get_container_command_variants(docker_watcher, labels, expected):
assert docker_watcher._get_container_command(container) == expected assert docker_watcher._get_container_command(container) == expected
def test_auto_command_env_override(monkeypatch, docker_watcher):
monkeypatch.setenv("WEBTERM_DOCKER_AUTO_COMMAND", "/bin/sh")
container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}}
assert docker_watcher._get_container_command(container) == "docker exec -it my-container /bin/sh"
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
("status", "body", "expected"), ("status", "body", "expected"),
+1
View File
@@ -464,6 +464,7 @@ class TestLocalServerMoreCoverage:
assert "data-session-websocket-url" in resp.text assert "data-session-websocket-url" in resp.text
assert "data-font-size" in resp.text assert "data-font-size" in resp.text
assert "data-scrollback" in resp.text assert "data-scrollback" in resp.text
assert 'data-theme="xterm"' in resp.text
assert "<title>Known</title>" in resp.text assert "<title>Known</title>" in resp.text
@pytest.mark.asyncio @pytest.mark.asyncio