Bump version to 1.1.2
This commit is contained in:
@@ -26,5 +26,4 @@ tests
|
|||||||
docs
|
docs
|
||||||
examples
|
examples
|
||||||
Dockerfile
|
Dockerfile
|
||||||
README.md
|
|
||||||
LICENSE
|
LICENSE
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# Copy only what's needed for installation
|
# Copy only what's needed for installation
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY pyproject.toml poetry.lock* ./
|
COPY pyproject.toml poetry.lock* ./
|
||||||
|
COPY README.md ./
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
# Install the package
|
# Install the package
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user