From 11bd4204aaa40a2b9b2d0d3ec57170858d901c97 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Wed, 29 Apr 2026 22:25:39 +0300 Subject: [PATCH] refactor: update terminal api --- API.md | 42 +- app/library/TerminalSessionManager.py | 92 +++- app/routes/api/system.py | 14 + app/tests/helpers.py | 2 +- app/tests/test_terminal_session_manager.py | 139 +++++- ui/app/composables/useConsoleSession.ts | 228 +++++++++ ui/app/pages/console.vue | 236 +++++++-- ui/app/spa-loading-template.html | 2 +- ui/app/types/index.d.ts | 27 +- ui/bun.lock | 472 ++++++++++-------- ui/nuxt.config.ts | 3 + ui/package.json | 24 +- .../composables/useConsoleSession.test.ts | 137 ++++- uv.lock | 286 +++++------ 14 files changed, 1268 insertions(+), 436 deletions(-) diff --git a/API.md b/API.md index 20514c2f..4a1559c9 100644 --- a/API.md +++ b/API.md @@ -98,6 +98,7 @@ This document describes the available endpoints and their usage. All endpoints r - [GET /api/system/configuration](#get-apisystemconfiguration) - [GET /api/system/limits](#get-apisystemlimits) - [POST /api/system/terminal](#post-apisystemterminal) + - [GET /api/system/terminal](#get-apisystemterminal) - [GET /api/system/terminal/active](#get-apisystemterminalactive) - [GET /api/system/terminal/{session\_id}](#get-apisystemterminalsession_id) - [DELETE /api/system/terminal/{session\_id}](#delete-apisystemterminalsession_id) @@ -2579,6 +2580,36 @@ or an error: --- +### GET /api/system/terminal +**Purpose**: List recent terminal sessions. + +**Response**: +```json +{ + "items": [ + { + "session_id": "3a8c5f7e2d3b4a8f9c0d1e2f3a4b5c6d", + "command": "--help", + "status": "completed", + "created_at": 1713000000.0, + "started_at": 1713000000.0, + "finished_at": 1713000004.0, + "expires_at": 1713086404.0, + "available_until": 1713086404.0, + "exit_code": 0, + "last_sequence": 15 + } + ] +} +``` + +**Notes**: +- Expired sessions are removed automatically later. + +- `403 Forbidden` if console is disabled. + +--- + ### GET /api/system/terminal/active **Purpose**: Return the currently active terminal session metadata, or `null` if no session is active. @@ -2606,7 +2637,7 @@ or --- ### GET /api/system/terminal/{session_id} -**Purpose**: Return metadata for a specific terminal session while it is active or still within the replay/drain window. +**Purpose**: Return metadata for a specific terminal session. **Response**: ```json @@ -2617,7 +2648,7 @@ or "created_at": 1713000000.0, "started_at": 1713000000.0, "finished_at": 1713000004.0, - "expires_at": 1713000034.0, + "expires_at": 1713086404.0, "exit_code": 0, "last_sequence": 15 } @@ -2641,8 +2672,7 @@ or **Notes**: - This only applies to the currently active session. -- The client should stay attached to the stream to receive the final `close` event and refreshed terminal status. -- Cancelled sessions finalize as `interrupted` and remain replayable until the drain window expires. +- Cancelled sessions are marked as `interrupted`. - `403 Forbidden` if console is disabled. - `404 Not Found` if the session does not exist or has already expired. @@ -2674,10 +2704,6 @@ If both `since` and `Last-Event-ID` are present, the larger value is used. { "exitcode": 0 } ``` -**Notes**: -- Replay/restore works while the session is still running or until the finished session expires. -- Finished sessions are removed lazily after the transcript drain window elapses. - - `403 Forbidden` if console is disabled. - `400 Bad Request` if the replay cursor is invalid. - `404 Not Found` if the session does not exist or has already expired. diff --git a/app/library/TerminalSessionManager.py b/app/library/TerminalSessionManager.py index 423dd131..07edb9a1 100644 --- a/app/library/TerminalSessionManager.py +++ b/app/library/TerminalSessionManager.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any from aiohttp import web from app.library.config import Config +from app.library.Scheduler import Scheduler from app.library.Services import Services from app.library.Singleton import Singleton @@ -31,8 +32,10 @@ ACTIVE_FILE_NAME = "active.json" METADATA_FILE_NAME = "metadata.json" TRANSCRIPT_FILE_NAME = "transcript.jsonl" DEFAULT_DRAIN_TTL = 30.0 +COMPLETED_SESSION_RETENTION = 86400.0 DEFAULT_KEEPALIVE_INTERVAL = 15.0 DEFAULT_SHUTDOWN_TIMEOUT = 5.0 +CLEANUP_SCHEDULE = "5 */1 * * *" class TerminalSessionConflictError(RuntimeError): @@ -55,8 +58,10 @@ class TerminalSessionManager(metaclass=Singleton): self._lock = asyncio.Lock() self._active: ActiveTerminalSession | None = None self._drain_ttl: float = DEFAULT_DRAIN_TTL + self._completed_retention: float = COMPLETED_SESSION_RETENTION self._keepalive_interval: float = DEFAULT_KEEPALIVE_INTERVAL self._shutdown_timeout: float = DEFAULT_SHUTDOWN_TIMEOUT + self._cleanup_job_id: str | None = None @staticmethod def get_instance() -> TerminalSessionManager: @@ -67,11 +72,20 @@ class TerminalSessionManager(metaclass=Singleton): Services.get_instance().add("terminal_manager", self) app.on_startup.append(self.on_startup) app.on_shutdown.append(self.on_shutdown) + self._cleanup_job_id = Scheduler.get_instance().add( + timer=CLEANUP_SCHEDULE, + func=self.cleanup, + id=f"{__class__.__name__}.{__class__.cleanup.__name__}", + ) async def on_startup(self, _: web.Application) -> None: await self.initialize() async def on_shutdown(self, _: web.Application) -> None: + if self._cleanup_job_id is not None: + Scheduler.get_instance().remove(self._cleanup_job_id) + self._cleanup_job_id = None + session_id: str | None = None task: asyncio.Task[None] | None = None @@ -121,12 +135,17 @@ class TerminalSessionManager(metaclass=Singleton): async def cleanup(self) -> None: async with self._lock: - self._cleanup_expired_sessions(time.time()) + LOG.debug("Running scheduled terminal session cleanup.") + removed = self._cleanup_expired_sessions(time.time()) + LOG.debug("Scheduled terminal session cleanup finished. Removed %d expired sessions.", removed) + + async def list_sessions(self) -> list[dict[str, Any]]: + async with self._lock: + return self._list_sessions_locked() async def create_session(self, command: str) -> dict[str, Any]: async with self._lock: now = time.time() - self._cleanup_expired_sessions(now) active_session = self._get_active_session_locked() if active_session is not None: @@ -160,22 +179,20 @@ class TerminalSessionManager(metaclass=Singleton): async def get_active_session(self) -> dict[str, Any] | None: async with self._lock: - self._cleanup_expired_sessions(time.time()) metadata = self._get_active_session_locked() return None if metadata is None else dict(metadata) async def get_session(self, session_id: str) -> dict[str, Any] | None: async with self._lock: - self._cleanup_expired_sessions(time.time()) metadata = self._load_metadata(session_id) - return None if metadata is None else dict(metadata) + if metadata is None or self._is_expired_metadata(metadata, time.time()): + return None + return dict(metadata) async def cancel_session(self, session_id: str) -> dict[str, Any]: async with self._lock: - self._cleanup_expired_sessions(time.time()) - metadata = self._load_metadata(session_id) - if metadata is None: + if metadata is None or self._is_expired_metadata(metadata, time.time()): msg = f"Unknown terminal session '{session_id}'." raise FileNotFoundError(msg) @@ -218,6 +235,9 @@ class TerminalSessionManager(metaclass=Singleton): async with self._lock: metadata = self._load_metadata(session_id) if metadata is not None: + if self._is_expired_metadata(metadata, time.time()): + return response + replay_until = int(metadata.get("last_sequence", last_sent)) runtime = self._active @@ -443,7 +463,7 @@ class TerminalSessionManager(metaclass=Singleton): metadata["status"] = status metadata["exit_code"] = exit_code metadata["finished_at"] = now - metadata["expires_at"] = now + self._drain_ttl + metadata["expires_at"] = now + self._completed_retention self._write_json(self._metadata_path(session_id), metadata) if self._load_active_marker() == session_id: @@ -468,6 +488,31 @@ class TerminalSessionManager(metaclass=Singleton): return metadata + def _list_sessions_locked(self) -> list[dict[str, Any]]: + self._ensure_root() + items: list[dict[str, Any]] = [] + + for path in self.root_path.iterdir(): + if path.name == ACTIVE_FILE_NAME or path.is_dir() is False: + continue + + metadata = self._load_metadata(path.name) + if metadata is None: + continue + + if self._is_expired_metadata(metadata, time.time()): + continue + + item = dict(metadata) + item["available_until"] = self._available_until(metadata) + items.append(item) + + items.sort( + key=lambda item: float(item.get("finished_at") or item.get("started_at") or item.get("created_at") or 0), + reverse=True, + ) + return items + def _recover_orphaned_active_session(self, now: float) -> None: session_id = self._load_active_marker() if session_id is None: @@ -480,14 +525,15 @@ class TerminalSessionManager(metaclass=Singleton): metadata["status"] = "interrupted" metadata["finished_at"] = now - metadata["expires_at"] = now + self._drain_ttl + metadata["expires_at"] = now + self._completed_retention metadata["exit_code"] = -1 if metadata.get("exit_code") is None else metadata["exit_code"] self._write_json(self._metadata_path(session_id), metadata) self._clear_active_marker() - def _cleanup_expired_sessions(self, now: float) -> None: + def _cleanup_expired_sessions(self, now: float) -> int: self._ensure_root() active_session_id = self._load_active_marker() + removed = 0 for path in self.root_path.iterdir(): if path.name == ACTIVE_FILE_NAME or path.is_dir() is False: @@ -496,10 +542,10 @@ class TerminalSessionManager(metaclass=Singleton): metadata = self._load_metadata(path.name) if metadata is None: shutil.rmtree(path, ignore_errors=True) + removed += 1 continue - expires_at = metadata.get("expires_at") - if expires_at is None or float(expires_at) > now: + if not self._is_expired_metadata(metadata, now): continue if active_session_id == path.name: @@ -508,6 +554,10 @@ class TerminalSessionManager(metaclass=Singleton): shutil.rmtree(path, ignore_errors=True) + removed += 1 + + return removed + def _parse_since(self, request: Request) -> int: values: list[int] = [] candidates = [request.query.get("since"), request.headers.get("Last-Event-ID")] @@ -537,7 +587,7 @@ class TerminalSessionManager(metaclass=Singleton): now = time.time() metadata["status"] = "interrupted" metadata["finished_at"] = now - metadata["expires_at"] = now + self._drain_ttl + metadata["expires_at"] = now + self._completed_retention metadata["exit_code"] = -1 if metadata.get("exit_code") is None else metadata["exit_code"] self._write_json(self._metadata_path(session_id), metadata) @@ -618,6 +668,20 @@ class TerminalSessionManager(metaclass=Singleton): return events + def _available_until(self, metadata: dict[str, Any]) -> float | None: + expires_at = metadata.get("expires_at") + if expires_at is None: + return None + + return float(expires_at) + + def _is_expired_metadata(self, metadata: dict[str, Any], now: float) -> bool: + expires_at = self._available_until(metadata) + if expires_at is None: + return False + + return expires_at <= now + def _build_env(self) -> dict[str, str]: env_vars = os.environ.copy() env_vars.update( diff --git a/app/routes/api/system.py b/app/routes/api/system.py index 5d157156..f45f5f3f 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -274,6 +274,20 @@ async def create_terminal_session( return web.json_response(data=metadata, status=web.HTTPOk.status_code, dumps=encoder.encode) +@route("GET", "api/system/terminal", "system.terminal.list") +async def list_terminal_sessions( + config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager +) -> Response: + if not config.console_enabled: + return web.json_response( + {"error": "Console feature is disabled."}, + status=web.HTTPForbidden.status_code, + ) + + items = await terminal_manager.list_sessions() + return web.json_response(data={"items": items}, status=web.HTTPOk.status_code, dumps=encoder.encode) + + @route("GET", "api/system/terminal/active", "system.terminal.active") async def get_active_terminal_session( config: Config, encoder: Encoder, terminal_manager: TerminalSessionManager diff --git a/app/tests/helpers.py b/app/tests/helpers.py index b01f8c21..d1a48027 100644 --- a/app/tests/helpers.py +++ b/app/tests/helpers.py @@ -11,7 +11,7 @@ from uuid import uuid4 if TYPE_CHECKING: from collections.abc import Iterator -_TEST_RUN_ROOT = Path(gettempdir()) / "ytptube-tests" / uuid4().hex +_TEST_RUN_ROOT = Path(gettempdir()) / "tests-ytptube" / uuid4().hex _TEST_SYSTEM_TEMP_ROOT = _TEST_RUN_ROOT / "tmp" diff --git a/app/tests/test_terminal_session_manager.py b/app/tests/test_terminal_session_manager.py index f2962db4..1407683d 100644 --- a/app/tests/test_terminal_session_manager.py +++ b/app/tests/test_terminal_session_manager.py @@ -1,10 +1,13 @@ import asyncio import json from pathlib import Path +import time from typing import Any, cast import pytest +from aiohttp import web +from app.library.Scheduler import Scheduler from app.library.Services import Services from app.library.TerminalSessionManager import TerminalSessionManager from app.library.config import Config @@ -13,6 +16,7 @@ from app.routes.api.system import ( cancel_terminal_session, create_terminal_session, get_active_terminal_session, + list_terminal_sessions, get_terminal_session, stream_terminal_session, ) @@ -133,6 +137,7 @@ class _TerminableProc: @pytest.fixture def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Encoder]: + Scheduler._reset_singleton() Services._reset_singleton() Config._reset_singleton() TerminalSessionManager._reset_singleton() @@ -150,6 +155,16 @@ def terminal_setup(tmp_path: Path) -> tuple[Config, TerminalSessionManager, Enco class TestTerminalSessionRoutes: + def test_attach_registers_cleanup_job(self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder]) -> None: + _config, manager, _encoder = terminal_setup + app = web.Application() + + manager.attach(app) + + scheduler = Scheduler.get_instance() + assert scheduler.has(f"{TerminalSessionManager.__name__}.{TerminalSessionManager.cleanup.__name__}") + assert manager._cleanup_job_id == f"{TerminalSessionManager.__name__}.{TerminalSessionManager.cleanup.__name__}" + @pytest.mark.asyncio async def test_start_returns_session_metadata_and_active_conflict( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch @@ -245,7 +260,7 @@ class TestTerminalSessionRoutes: self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch ) -> None: config, manager, encoder = terminal_setup - manager._drain_ttl = 0.05 + manager._completed_retention = 0.05 await manager.initialize() async def fake_create_subprocess_exec(*_args, **_kwargs): @@ -271,8 +286,130 @@ class TestTerminalSessionRoutes: expired = await manager.get_session(session_id) assert expired is None + assert (manager.root_path / session_id).exists() + + await manager.cleanup() + assert not (manager.root_path / session_id).exists() + @pytest.mark.asyncio + async def test_list_sessions_returns_recent_completed_session_until_retention_expires( + self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch + ) -> None: + config, manager, encoder = terminal_setup + manager._completed_retention = 0.2 + manager._drain_ttl = 0.01 + await manager.initialize() + + async def fake_create_subprocess_exec(*_args, **_kwargs): + return _CompletedProc([b"done\n"]) + + monkeypatch.setattr( + "app.library.TerminalSessionManager.asyncio.create_subprocess_exec", fake_create_subprocess_exec + ) + monkeypatch.setattr(manager, "_open_pty", lambda: None) + + start_request = _FakeRequest(payload={"command": "--help"}, can_read_body=True) + start_response = await create_terminal_session(start_request, config, encoder, manager) + session_id = json.loads(start_response.body.decode("utf-8"))["session_id"] + + assert manager._active is not None + await manager._active.task + + await asyncio.sleep(0.03) + + listed_response = await list_terminal_sessions(config, encoder, manager) + listed_payload = json.loads(listed_response.body.decode("utf-8")) + + assert 200 == listed_response.status + assert 1 == len(listed_payload["items"]) + assert session_id == listed_payload["items"][0]["session_id"] + assert "completed" == listed_payload["items"][0]["status"] + assert listed_payload["items"][0]["available_until"] is not None + + persisted = await manager.get_session(session_id) + assert persisted is not None + + await asyncio.sleep(0.19) + + expired = await manager.get_session(session_id) + assert expired is None + assert (manager.root_path / session_id).exists() + + await manager.cleanup() + + assert not (manager.root_path / session_id).exists() + + @pytest.mark.asyncio + async def test_list_sessions_orders_newest_first_and_skips_expired_items( + self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder] + ) -> None: + config, manager, encoder = terminal_setup + await manager.initialize() + + first_id = "first" + second_id = "second" + expired_id = "expired" + + for session_id in [first_id, second_id, expired_id]: + session_dir = manager.root_path / session_id + session_dir.mkdir(parents=True, exist_ok=True) + manager._transcript_path(session_id).touch(exist_ok=True) + + manager._write_json( + manager._metadata_path(first_id), + { + "session_id": first_id, + "command": "--first", + "status": "completed", + "created_at": 10.0, + "started_at": 11.0, + "finished_at": 20.0, + "expires_at": time.time() + 60, + "exit_code": 0, + "last_sequence": 2, + }, + ) + manager._write_json( + manager._metadata_path(second_id), + { + "session_id": second_id, + "command": "--second", + "status": "running", + "created_at": 30.0, + "started_at": 31.0, + "finished_at": None, + "expires_at": None, + "exit_code": None, + "last_sequence": 4, + }, + ) + manager._write_json( + manager._metadata_path(expired_id), + { + "session_id": expired_id, + "command": "--expired", + "status": "completed", + "created_at": 1.0, + "started_at": 2.0, + "finished_at": 3.0, + "expires_at": time.time() - 1, + "exit_code": 1, + "last_sequence": 1, + }, + ) + + listed_response = await list_terminal_sessions(config, encoder, manager) + listed_payload = json.loads(listed_response.body.decode("utf-8")) + + assert 200 == listed_response.status + assert [second_id, first_id] == [item["session_id"] for item in listed_payload["items"]] + assert (manager.root_path / expired_id).exists() + + await manager.cleanup() + + assert not (manager.root_path / expired_id).exists() + @pytest.mark.asyncio async def test_shutdown_interrupts_active_session_and_clears_active_marker( self, terminal_setup: tuple[Config, TerminalSessionManager, Encoder], monkeypatch: pytest.MonkeyPatch diff --git a/ui/app/composables/useConsoleSession.ts b/ui/app/composables/useConsoleSession.ts index 2b4ed6b0..930bcb55 100644 --- a/ui/app/composables/useConsoleSession.ts +++ b/ui/app/composables/useConsoleSession.ts @@ -3,6 +3,7 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source'; import { useStorage } from '@vueuse/core'; import { computed, ref } from 'vue'; +import type { TerminalSessionItem, TerminalSessionsResponse } from '~/types'; import { parse_api_error, parse_api_response, request, uri } from '~/utils'; type ConsoleSessionStatus = @@ -48,10 +49,25 @@ type CancelConsoleSessionResult = { message?: string; }; +type RecentSessionItem = { + sessionId: string; + command: string; + status: 'starting' | 'running' | 'completed'; + createdAt: number | null; + startedAt: number | null; + finishedAt: number | null; + expiresAt: number | null; + availableUntil: number | null; + exitCode: number | null; + lastSequence: number; +}; + const STORAGE_KEY = 'console_session_state'; +const HIDDEN_RECENT_SESSIONS_KEY = 'console_hidden_recent_sessions'; const MAX_TRANSCRIPT_CHUNKS = 1500; const MAX_TRANSCRIPT_CHARS = 120000; const RECONNECT_DELAY_MS = 1500; +const RECENT_SESSIONS_MAX = 30; const DEFAULT_STATE: ConsoleSessionState = { sessionId: null, @@ -160,6 +176,8 @@ const normalizePersistedState = ( }; const sessionState = useStorage(STORAGE_KEY, { ...DEFAULT_STATE }); +const hiddenRecentSessions = useStorage>(HIDDEN_RECENT_SESSIONS_KEY, []); +const recentSessions = ref>([]); const streamController = ref(null); const reconnectTimer = ref | null>(null); const connectionNonce = ref(0); @@ -176,6 +194,68 @@ const updateState = (patch: Partial): void => { ); }; +const normalizeHiddenRecentSessions = (): Array => { + return hiddenRecentSessions.value + .filter((value): value is string => typeof value === 'string') + .map((value) => value.trim()) + .filter((value) => Boolean(value)); +}; + +const setHiddenRecentSessions = (items: Array): void => { + hiddenRecentSessions.value = Array.from(new Set(items)); +}; + +const isRecentSessionHidden = (sessionId: string): boolean => { + return normalizeHiddenRecentSessions().includes(sessionId); +}; + +const sortRecentSessions = (items: Array): Array => { + return items.slice().sort((left, right) => { + const leftTime = left.finishedAt ?? left.startedAt ?? left.createdAt ?? 0; + const rightTime = right.finishedAt ?? right.startedAt ?? right.createdAt ?? 0; + return rightTime - leftTime; + }); +}; + +const trimRecentSessions = (items: Array): Array => { + return items.slice(0, RECENT_SESSIONS_MAX); +}; + +const setRecentSessions = (items: Array): void => { + recentSessions.value = trimRecentSessions( + sortRecentSessions(items.filter((item) => !isRecentSessionHidden(item.sessionId))), + ); +}; + +const upsertRecentSession = (item: RecentSessionItem): void => { + if (isRecentSessionHidden(item.sessionId)) { + return; + } + + setRecentSessions([ + item, + ...recentSessions.value.filter((entry) => entry.sessionId !== item.sessionId), + ]); +}; + +const hideRecentSession = (sessionId: string): void => { + const normalized = sessionId.trim(); + if (!normalized) { + return; + } + + setHiddenRecentSessions([...normalizeHiddenRecentSessions(), normalized]); + setRecentSessions(recentSessions.value.filter((item) => item.sessionId !== normalized)); +}; + +const clearRecentSessions = (): void => { + setHiddenRecentSessions([ + ...normalizeHiddenRecentSessions(), + ...recentSessions.value.map((item) => item.sessionId), + ]); + recentSessions.value = []; +}; + const appendTranscript = (chunk: string): void => { if (!chunk) { return; @@ -215,6 +295,7 @@ const clearTranscript = (): void => { const markExpired = (): void => { stopStream(); updateState({ status: 'expired', error: '' }); + syncCurrentSessionToRecentSessions(); }; const finishSession = ( @@ -227,6 +308,7 @@ const finishSession = ( exitCode, error: '', }); + syncCurrentSessionToRecentSessions(); }; const scheduleReconnect = (): void => { @@ -289,6 +371,97 @@ const parseResponseError = async (response: Response): Promise => { return response.statusText || 'Request failed.'; }; +const normalizeRecentSessionStatus = ( + status: string | null | undefined, +): RecentSessionItem['status'] => { + switch (normalizeStatus(status, 'finished')) { + case 'starting': + return 'starting'; + + case 'running': + case 'reconnecting': + return 'running'; + + default: + return 'completed'; + } +}; + +const recentSessionFromPayload = (payload: TerminalSessionItem): RecentSessionItem | null => { + if (typeof payload.session_id !== 'string' || !payload.session_id.trim()) { + return null; + } + + if (typeof payload.command !== 'string') { + return null; + } + + return { + sessionId: payload.session_id, + command: payload.command, + status: normalizeRecentSessionStatus(payload.status), + createdAt: typeof payload.created_at === 'number' ? payload.created_at : null, + startedAt: typeof payload.started_at === 'number' ? payload.started_at : null, + finishedAt: typeof payload.finished_at === 'number' ? payload.finished_at : null, + expiresAt: typeof payload.expires_at === 'number' ? payload.expires_at : null, + availableUntil: typeof payload.available_until === 'number' ? payload.available_until : null, + exitCode: typeof payload.exit_code === 'number' ? payload.exit_code : null, + lastSequence: typeof payload.last_sequence === 'number' ? payload.last_sequence : 0, + }; +}; + +const syncCurrentSessionToRecentSessions = (): void => { + if (!sessionState.value.sessionId || !sessionState.value.command.trim()) { + return; + } + + const existing = + recentSessions.value.find((item) => item.sessionId === sessionState.value.sessionId) ?? null; + const isFinished = ['finished', 'interrupted', 'error', 'expired'].includes( + sessionState.value.status, + ); + const lastSequence = + sessionState.value.lastEventId && /^\d+$/.test(sessionState.value.lastEventId) + ? Number(sessionState.value.lastEventId) + : (existing?.lastSequence ?? 0); + + upsertRecentSession({ + sessionId: sessionState.value.sessionId, + command: sessionState.value.command, + status: normalizeRecentSessionStatus(sessionState.value.status), + createdAt: existing?.createdAt ?? null, + startedAt: existing?.startedAt ?? null, + finishedAt: isFinished + ? (existing?.finishedAt ?? Date.now() / 1000) + : (existing?.finishedAt ?? null), + expiresAt: existing?.expiresAt ?? null, + availableUntil: existing?.availableUntil ?? null, + exitCode: sessionState.value.exitCode ?? existing?.exitCode ?? null, + lastSequence, + }); +}; + +const fetchRecentSessions = async (): Promise => { + try { + const response = await request('/api/system/terminal'); + if (!response.ok) { + return; + } + + const payload = await parse_api_response(response.json()); + const items = Array.isArray(payload?.items) + ? payload.items + .map((item) => recentSessionFromPayload(item)) + .filter((item): item is RecentSessionItem => item !== null) + : []; + + setRecentSessions(items); + syncCurrentSessionToRecentSessions(); + } catch { + return; + } +}; + const refreshSessionMetadata = async (): Promise => { if (!sessionState.value.sessionId) { return; @@ -303,10 +476,14 @@ const refreshSessionMetadata = async (): Promise => { } updateState(normalizeResponse(payload)); + syncCurrentSessionToRecentSessions(); } catch (error) { if (error instanceof ConsoleSessionExpiredError) { markExpired(); + return; } + + return; } }; @@ -400,6 +577,7 @@ const connectStream = async (): Promise => { onopen: async (response) => { if (response.ok) { updateState({ status: 'running', error: '' }); + syncCurrentSessionToRecentSessions(); return; } @@ -434,6 +612,7 @@ const connectStream = async (): Promise => { if (event.event === 'status') { updateState(normalizeResponse(payload as ConsoleSessionResponse)); + syncCurrentSessionToRecentSessions(); return; } @@ -501,6 +680,7 @@ const connectStream = async (): Promise => { const message = error instanceof Error ? error.message : String(error); appendTranscript(`Error: ${message}\n`); updateState({ status: 'error', error: message }); + syncCurrentSessionToRecentSessions(); } finally { if (streamController.value === controller) { streamController.value = null; @@ -517,12 +697,15 @@ const restoreSession = async (): Promise => { if (payload) { updateState(normalizeResponse(payload)); + syncCurrentSessionToRecentSessions(); } if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) { await connectStream(); } + await fetchRecentSessions(); + return; } @@ -531,28 +714,36 @@ const restoreSession = async (): Promise => { sessionState.value.command || sessionState.value.transcript.length > 0 ) { + await fetchRecentSessions(); return; } const active = await readSessionResponse('/api/system/terminal/active', { allowMissing: true }); if (!active) { + await fetchRecentSessions(); return; } updateState(normalizeResponse(active)); + syncCurrentSessionToRecentSessions(); if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) { await connectStream(); } + + await fetchRecentSessions(); } catch (error) { if (error instanceof ConsoleSessionExpiredError) { markExpired(); + await fetchRecentSessions(); return; } if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) { await connectStream(); } + + await fetchRecentSessions(); } }; @@ -594,16 +785,20 @@ const startSession = async ({ ]), error: '', }); + syncCurrentSessionToRecentSessions(); if (sessionState.value.sessionId && isActiveSessionStatus(sessionState.value.status)) { await connectStream(); } + await fetchRecentSessions(); + return Boolean(sessionState.value.sessionId); } catch (error) { const message = error instanceof Error ? error.message : String(error); appendTranscript(`Error: ${message}\n`); updateState({ status: 'error', error: message }); + syncCurrentSessionToRecentSessions(); return false; } }; @@ -623,6 +818,7 @@ const cancelSession = async (): Promise => { if (response.status === 404) { await refreshSessionMetadata(); + await fetchRecentSessions(); return { status: 'missing', message: 'Terminal session not found.' }; } @@ -631,6 +827,7 @@ const cancelSession = async (): Promise => { } updateState({ error: '' }); + await fetchRecentSessions(); return { status: 'cancelled' }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -642,6 +839,32 @@ const cancelSession = async (): Promise => { const resetState = (): void => { stopStream(); sessionState.value = { ...DEFAULT_STATE }; + recentSessions.value = []; + hiddenRecentSessions.value = []; +}; + +const replaySession = async (item: RecentSessionItem): Promise => { + stopStream(); + updateState({ + sessionId: item.sessionId, + command: item.command, + status: item.status === 'running' ? 'reconnecting' : 'finished', + lastEventId: null, + exitCode: item.exitCode, + transcript: [], + error: '', + }); + syncCurrentSessionToRecentSessions(); + + await connectStream(); + await fetchRecentSessions(); + + if (sessionState.value.status === 'expired') { + hideRecentSession(item.sessionId); + return false; + } + + return sessionState.value.status !== 'error'; }; const useConsoleSession = () => { @@ -649,7 +872,12 @@ const useConsoleSession = () => { state: sessionState, bufferedTranscript: computed(() => sessionState.value.transcript.slice()), isLoading: computed(() => isActiveSessionStatus(sessionState.value.status)), + recentSessions: computed(() => recentSessions.value.slice()), clearTranscript, + clearRecentSessions, + fetchRecentSessions, + hideRecentSession, + replaySession, restoreSession, startSession, cancelSession, diff --git a/ui/app/pages/console.vue b/ui/app/pages/console.vue index 3fc2d9a1..8a943c84 100644 --- a/ui/app/pages/console.vue +++ b/ui/app/pages/console.vue @@ -177,27 +177,27 @@ class="flex items-center gap-2 text-sm font-semibold text-highlighted" > - Command history + Recent runs - Clear history + Hide recent runs
- +
@@ -315,6 +400,7 @@
- +
+ + +
+ + + + {{ recentSessionStatusLabel(item) }} + + + + {{ recentSessionStatusLabel(item) }} + + + + + + + + + +
+
- +
+ + Fill + + + +