From 074832cff20e51cc1d13ff872cc3bb3fdbdc93d1 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Thu, 29 Jan 2026 17:51:51 +0000 Subject: [PATCH] fix: CI/CD + server bugs --- .github/workflows/docker.yml | 88 ++++++++++++++++++++++++++++++++++++ src/webterm/local_server.py | 31 ++++++++----- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0206916..e1316ee 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -258,3 +258,91 @@ jobs: core.warning(`Failed to delete artifact ${artifact.id}: ${error.message}`); } } + + prune-docker-images: + name: Prune old Docker images + runs-on: ubuntu-latest + needs: merge + permissions: + packages: write + steps: + - name: Delete untagged images + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.RELEASES_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const keepCount = 5; + const { owner, repo } = context.repo; + const packageName = repo.toLowerCase(); + + core.info(`Cleaning Docker images for repository: ${owner}/${repo}`); + core.info(`Package name in registry: ${packageName}`); + + try { + // Get all package versions for THIS repository's container package only + const versions = await github.paginate( + github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, + { + package_type: 'container', + package_name: packageName, // This filters to only this repo's images + org: owner, + per_page: 100, + state: 'active', + } + ); + + core.info(`Found ${versions.length} package versions`); + + // Separate tagged and untagged versions + const taggedVersions = versions.filter(v => v.metadata?.container?.tags?.length > 0); + const untaggedVersions = versions.filter(v => !v.metadata?.container?.tags || v.metadata.container.tags.length === 0); + + core.info(`Tagged versions: ${taggedVersions.length}, Untagged versions: ${untaggedVersions.length}`); + + // Delete all untagged versions + for (const version of untaggedVersions) { + core.info(`Deleting untagged version ${version.id} (created: ${version.created_at})`); + try { + await github.rest.packages.deletePackageVersionForOrg({ + package_type: 'container', + package_name: packageName, + org: owner, + package_version_id: version.id, + }); + } catch (error) { + core.warning(`Failed to delete untagged version ${version.id}: ${error.message}`); + } + } + + // Sort tagged versions by creation date (newest first) + const sortedTagged = taggedVersions.sort((a, b) => + new Date(b.created_at) - new Date(a.created_at) + ); + + // Keep the newest versions, delete the rest + const versionsToDelete = sortedTagged.slice(keepCount); + + core.info(`Keeping ${keepCount} newest tagged versions, deleting ${versionsToDelete.length} older ones...`); + for (const version of versionsToDelete) { + const tags = version.metadata?.container?.tags?.join(', ') || 'unknown'; + core.info(`Deleting version ${version.id} with tags: ${tags} (created: ${version.created_at})`); + try { + await github.rest.packages.deletePackageVersionForOrg({ + package_type: 'container', + package_name: packageName, + org: owner, + package_version_id: version.id, + }); + } catch (error) { + core.warning(`Failed to delete version ${version.id}: ${error.message}`); + } + } + + } catch (error) { + if (error.status === 404) { + core.info('No package found - this might be the first build'); + } else { + core.setFailed(`Error managing package versions: ${error.message}`); + } + } + diff --git a/src/webterm/local_server.py b/src/webterm/local_server.py index 5615e66..6a63c36 100644 --- a/src/webterm/local_server.py +++ b/src/webterm/local_server.py @@ -17,6 +17,7 @@ from aiohttp import WSMsgType, web from . import constants from .docker_stats import DockerStatsCollector, render_sparkline_svg +from .docker_watcher import AUTO_COMMAND_SENTINEL from .exit_poller import ExitPoller from .identity import generate from .poller import Poller @@ -34,7 +35,7 @@ DEFAULT_TERMINAL_SIZE = (132, 45) SCREENSHOT_CACHE_SECONDS = 0.3 SCREENSHOT_MAX_CACHE_SECONDS = 20.0 -CLEAR_AND_REDRAW_SEQ = "\x0c" # Ctrl+L: clear and redraw +CLEAR_AND_REDRAW_SEQ = "\x1b[2J\x1b[H\x1b[3J" # Clear screen and scrollback, move to home WEBTERM_STATIC_PATH = Path(__file__).parent / "static" @@ -83,6 +84,13 @@ class LocalClientConnector(SessionConnector): await self.server.handle_session_close(self.session_id, self.route_key) +def _format_command_label(command: str) -> str: + """Format command for display in UI, replacing sentinel with readable label.""" + if command == AUTO_COMMAND_SENTINEL: + return "(tmux persistent session)" + return command + + class LocalServer: def mark_route_activity(self, route_key: str) -> None: try: @@ -312,15 +320,15 @@ class LocalServer: await runner.setup() stack.push_async_callback(runner.cleanup) - # Start Docker stats collector in compose mode - if self._compose_mode and self._landing_apps: + # Start Docker stats collector in compose mode or docker watch mode + if (self._compose_mode and self._landing_apps) or self._docker_watch_mode: self._docker_stats = DockerStatsCollector(compose_project=self._compose_project) if self._docker_stats.available: # Pass service names (not slugs) for Docker matching - service_names = [app.name for app in self._landing_apps] + service_names = [app.name for app in (self._landing_apps if self._compose_mode else self.session_manager.apps)] self._docker_stats.start(service_names) # Create slug->name mapping for lookups - self._slug_to_service = {app.slug: app.name for app in self._landing_apps} + self._slug_to_service = {app.slug: app.name for app in (self._landing_apps if self._compose_mode else self.session_manager.apps)} log.info("Slug to service mapping: %s", self._slug_to_service) stack.push_async_callback(self._docker_stats.stop) @@ -538,7 +546,7 @@ class LocalServer: if session_process is not None: await asyncio.sleep(0.5) - if session_process is None or not hasattr(session_process, "get_screen_state"): + if session_process is None or not hasattr(session_process, "get_screen_snapshot"): raise web.HTTPNotFound(text="Session not found") # Get the actual screen state from the terminal session's pyte screen @@ -707,7 +715,7 @@ class LocalServer: apps_for_dashboard = self._landing_apps tiles = [ - {"slug": app.slug, "name": app.name, "command": app.command} + {"slug": app.slug, "name": app.name, "command": _format_command_label(app.command)} for app in apps_for_dashboard ] return web.json_response(tiles) @@ -727,18 +735,19 @@ class LocalServer: apps_for_dashboard = self._landing_apps tiles = [ - {"slug": app.slug, "name": app.name, "command": app.command} + {"slug": app.slug, "name": app.name, "command": _format_command_label(app.command)} for app in apps_for_dashboard ] tiles_json = json.dumps(tiles) - compose_mode_js = "true" if self._compose_mode else "false" + # Show CPU sparklines in both compose mode and docker watch mode + compose_mode_js = "true" if (self._compose_mode or self._docker_watch_mode) else "false" docker_watch_js = "true" if self._docker_watch_mode else "false" html_content = f""" Session Dashboard