feat(embed): add programmatic UI (control plane) management (#683)
* feat(embed): add programmatic UI (control plane) management Add ability to start/stop the web UI from hindsight-embed, with configurable port (default: daemon_port + 10000) and hostname (default: 0.0.0.0). Uses npx to run the published control plane package, or node directly in dev mode. New CLI commands: hindsight-embed ui start [--port PORT] [--hostname HOST] hindsight-embed ui stop [--port PORT] hindsight-embed ui status [--port PORT] hindsight-embed ui logs [-f] [-n N] New programmatic API: daemon_client.start_ui(profile, ui_port, hostname) daemon_client.stop_ui(profile, ui_port) daemon_client.is_ui_running(profile, ui_port) daemon_client.get_ui_url(profile, ui_port) * feat(embed): expose UI management on HindsightEmbedded Add start_ui(), stop_ui(), is_ui_running(), and ui_url property to HindsightEmbedded so the UI can be started programmatically: client = HindsightEmbedded(profile="myapp", ...) client.start_ui() # starts daemon + UI print(client.ui_url)
This commit is contained in:
parent
db70fdbe5e
commit
b42b35bf93
5 changed files with 523 additions and 4 deletions
|
|
@ -34,7 +34,6 @@ Using context manager:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import threading
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -140,13 +139,17 @@ class HindsightEmbedded:
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._closed:
|
if self._closed:
|
||||||
raise RuntimeError("Cannot use HindsightEmbedded after it has been closed")
|
raise RuntimeError(
|
||||||
|
"Cannot use HindsightEmbedded after it has been closed"
|
||||||
|
)
|
||||||
|
|
||||||
# Use embed manager interface for daemon management
|
# Use embed manager interface for daemon management
|
||||||
logger.info(f"Ensuring daemon is running for profile '{self.profile}'...")
|
logger.info(f"Ensuring daemon is running for profile '{self.profile}'...")
|
||||||
success = self._manager.ensure_running(self.config, self.profile)
|
success = self._manager.ensure_running(self.config, self.profile)
|
||||||
if not success:
|
if not success:
|
||||||
raise RuntimeError(f"Failed to start daemon for profile '{self.profile}'")
|
raise RuntimeError(
|
||||||
|
f"Failed to start daemon for profile '{self.profile}'"
|
||||||
|
)
|
||||||
|
|
||||||
# Get daemon URL and create client
|
# Get daemon URL and create client
|
||||||
daemon_url = self._manager.get_url(self.profile)
|
daemon_url = self._manager.get_url(self.profile)
|
||||||
|
|
@ -375,3 +378,45 @@ class HindsightEmbedded:
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check if the client is initialized."""
|
"""Check if the client is initialized."""
|
||||||
return self._started and not self._closed and self._client is not None
|
return self._started and not self._closed and self._client is not None
|
||||||
|
|
||||||
|
def start_ui(self, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool:
|
||||||
|
"""Start the control plane web UI.
|
||||||
|
|
||||||
|
The daemon is started automatically if not already running.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ui_port: Port for the UI. Defaults to daemon_port + 10000.
|
||||||
|
hostname: Hostname to bind to. Defaults to 0.0.0.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if UI started successfully.
|
||||||
|
"""
|
||||||
|
self._ensure_started()
|
||||||
|
return self._manager.start_ui(self.profile, ui_port, hostname)
|
||||||
|
|
||||||
|
def stop_ui(self, ui_port: int | None = None) -> bool:
|
||||||
|
"""Stop the control plane web UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ui_port: Port the UI is running on. Defaults to daemon_port + 10000.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if stopped successfully.
|
||||||
|
"""
|
||||||
|
return self._manager.stop_ui(self.profile, ui_port)
|
||||||
|
|
||||||
|
def is_ui_running(self, ui_port: int | None = None) -> bool:
|
||||||
|
"""Check if the control plane web UI is running.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ui_port: Port to check. Defaults to daemon_port + 10000.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if UI is running and responsive.
|
||||||
|
"""
|
||||||
|
return self._manager.is_ui_running(self.profile, ui_port)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ui_url(self) -> str:
|
||||||
|
"""Get the UI URL for this profile."""
|
||||||
|
return self._manager.get_ui_url(self.profile)
|
||||||
|
|
|
||||||
|
|
@ -683,6 +683,167 @@ def do_daemon(args, config: dict, logger):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def do_ui(args, config: dict, logger):
|
||||||
|
"""Handle UI subcommands."""
|
||||||
|
from . import daemon_client
|
||||||
|
from .profile_manager import UI_PORT_OFFSET, ProfileManager
|
||||||
|
|
||||||
|
profile = args.profile
|
||||||
|
ui_port = getattr(args, "port", None)
|
||||||
|
hostname = getattr(args, "hostname", "0.0.0.0")
|
||||||
|
|
||||||
|
# Resolve default UI port
|
||||||
|
pm = ProfileManager()
|
||||||
|
paths = pm.resolve_profile_paths(profile or "")
|
||||||
|
default_ui_port = paths.port + UI_PORT_OFFSET
|
||||||
|
|
||||||
|
if args.ui_command == "start":
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
effective_port = ui_port or default_ui_port
|
||||||
|
|
||||||
|
if daemon_client.is_ui_running(profile, effective_port):
|
||||||
|
title = (
|
||||||
|
f"[bold yellow]UI Already Running[/bold yellow] [dim]({profile or 'default'} @ :{effective_port})[/dim]"
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text("UI is already running", style="yellow"),
|
||||||
|
title=title,
|
||||||
|
border_style="yellow",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Ensure daemon is running first
|
||||||
|
if not daemon_client.is_daemon_running(profile):
|
||||||
|
console.print("[dim]Daemon not running, starting it first...[/dim]")
|
||||||
|
if not daemon_client.ensure_daemon_running(config, profile):
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text("Failed to start daemon (required for UI)", style="red"),
|
||||||
|
title="[bold red]✗ Error[/bold red]",
|
||||||
|
border_style="red",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if daemon_client.start_ui(profile, ui_port, hostname):
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
elif args.ui_command == "stop":
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
effective_port = ui_port or default_ui_port
|
||||||
|
|
||||||
|
if not daemon_client.is_ui_running(profile, effective_port):
|
||||||
|
title = f"[bold]UI Status[/bold] [dim]({profile or 'default'})[/dim]"
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text("UI is not running", style="dim"),
|
||||||
|
title=title,
|
||||||
|
border_style="dim",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if daemon_client.stop_ui(profile, ui_port):
|
||||||
|
title = f"[bold green]✓ UI Stopped[/bold green] [dim]({profile or 'default'})[/dim]"
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text("UI stopped successfully", style="green"),
|
||||||
|
title=title,
|
||||||
|
border_style="green",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text("Failed to stop UI", style="red"),
|
||||||
|
title="[bold red]✗ Error[/bold red]",
|
||||||
|
border_style="red",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
elif args.ui_command == "status":
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
effective_port = ui_port or default_ui_port
|
||||||
|
|
||||||
|
if daemon_client.is_ui_running(profile, effective_port):
|
||||||
|
status_text = Text()
|
||||||
|
status_text.append("UI is running\n\n", style="green bold")
|
||||||
|
status_text.append(" URL: ", style="dim")
|
||||||
|
status_text.append(f"http://127.0.0.1:{effective_port}\n", style="cyan")
|
||||||
|
status_text.append(" Logs: ", style="dim")
|
||||||
|
status_text.append(f"{paths.ui_log}", style="")
|
||||||
|
|
||||||
|
title = f"[bold green]✓ UI Running[/bold green] [dim]({profile or 'default'} @ :{effective_port})[/dim]"
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
status_text,
|
||||||
|
title=title,
|
||||||
|
border_style="green",
|
||||||
|
padding=(1, 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
title = f"[bold]UI Status[/bold] [dim]({profile or 'default'})[/dim]"
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text("UI is not running", style="dim"),
|
||||||
|
title=title,
|
||||||
|
border_style="dim",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
elif args.ui_command == "logs":
|
||||||
|
ui_log_path = paths.ui_log
|
||||||
|
if not ui_log_path.exists():
|
||||||
|
print("No UI logs found", file=sys.stderr)
|
||||||
|
print(f" Expected at: {ui_log_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.follow:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(["tail", "-f", str(ui_log_path)])
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(ui_log_path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
for line in lines[-args.lines :]:
|
||||||
|
print(line, end="")
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading logs: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Usage: hindsight-embed ui {start|stop|status|logs}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _do_configure_profile_with_env(profile_name: str, port: int | None, env_vars: list[str]) -> int:
|
def _do_configure_profile_with_env(profile_name: str, port: int | None, env_vars: list[str]) -> int:
|
||||||
"""Configure a named profile with environment variables (non-interactive).
|
"""Configure a named profile with environment variables (non-interactive).
|
||||||
|
|
||||||
|
|
@ -1247,6 +1408,30 @@ def main():
|
||||||
exit_code = do_daemon(args, config, logger)
|
exit_code = do_daemon(args, config, logger)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
# Handle UI subcommands
|
||||||
|
if command == "ui":
|
||||||
|
parser = argparse.ArgumentParser(prog="hindsight-embed ui")
|
||||||
|
subparsers = parser.add_subparsers(dest="ui_command")
|
||||||
|
start_parser = subparsers.add_parser("start", help="Start the UI")
|
||||||
|
start_parser.add_argument("--port", type=int, help="Port for the UI (default: daemon_port + 10000)")
|
||||||
|
start_parser.add_argument(
|
||||||
|
"--hostname", "-H", default="0.0.0.0", help="Hostname to bind to (default: 0.0.0.0)"
|
||||||
|
)
|
||||||
|
stop_parser = subparsers.add_parser("stop", help="Stop the UI")
|
||||||
|
stop_parser.add_argument("--port", type=int, help="Port the UI is running on")
|
||||||
|
status_parser = subparsers.add_parser("status", help="Check UI status")
|
||||||
|
status_parser.add_argument("--port", type=int, help="Port to check")
|
||||||
|
logs_parser = subparsers.add_parser("logs", help="View UI logs")
|
||||||
|
logs_parser.add_argument("--follow", "-f", action="store_true")
|
||||||
|
logs_parser.add_argument("--lines", "-n", type=int, default=50)
|
||||||
|
|
||||||
|
args = parser.parse_args(remaining_args[1:])
|
||||||
|
args.profile = global_profile
|
||||||
|
logger = setup_logging(False)
|
||||||
|
config = get_config()
|
||||||
|
exit_code = do_ui(args, config, logger)
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# Handle --help / -h
|
# Handle --help / -h
|
||||||
if command in ("--help", "-h"):
|
if command in ("--help", "-h"):
|
||||||
print_help()
|
print_help()
|
||||||
|
|
@ -1305,6 +1490,12 @@ Daemon management:
|
||||||
daemon status Check daemon status
|
daemon status Check daemon status
|
||||||
daemon logs [-f] [-n] View daemon logs
|
daemon logs [-f] [-n] View daemon logs
|
||||||
|
|
||||||
|
UI (control plane):
|
||||||
|
ui start [--port PORT] [--hostname HOST] Start the web UI (default port: daemon_port + 10000)
|
||||||
|
ui stop [--port PORT] Stop the web UI
|
||||||
|
ui status [--port PORT] Check UI status
|
||||||
|
ui logs [-f] [-n] View UI logs
|
||||||
|
|
||||||
CLI commands (forwarded to hindsight-cli):
|
CLI commands (forwarded to hindsight-cli):
|
||||||
memory retain <bank> <content> Store a memory
|
memory retain <bank> <content> Store a memory
|
||||||
memory recall <bank> <query> Search memories
|
memory recall <bank> <query> Search memories
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,67 @@ def is_daemon_running(profile: str | None = None) -> bool:
|
||||||
return _manager.is_running(profile)
|
return _manager.is_running(profile)
|
||||||
|
|
||||||
|
|
||||||
|
def start_ui(profile: str | None = None, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool:
|
||||||
|
"""Start the control plane UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: Profile name (None = resolve from priority).
|
||||||
|
ui_port: Port for the UI. Defaults to daemon_port + 10000.
|
||||||
|
hostname: Hostname to bind to. Defaults to 0.0.0.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if UI started successfully.
|
||||||
|
"""
|
||||||
|
if profile is None:
|
||||||
|
profile = resolve_active_profile()
|
||||||
|
return _manager.start_ui(profile, ui_port, hostname)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_ui(profile: str | None = None, ui_port: int | None = None) -> bool:
|
||||||
|
"""Stop the control plane UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: Profile name (None = resolve from priority).
|
||||||
|
ui_port: Port the UI is running on. Defaults to daemon_port + 10000.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if UI stopped successfully.
|
||||||
|
"""
|
||||||
|
if profile is None:
|
||||||
|
profile = resolve_active_profile()
|
||||||
|
return _manager.stop_ui(profile, ui_port)
|
||||||
|
|
||||||
|
|
||||||
|
def is_ui_running(profile: str | None = None, ui_port: int | None = None) -> bool:
|
||||||
|
"""Check if the UI is running.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: Profile name (None = resolve from priority).
|
||||||
|
ui_port: Port to check. Defaults to daemon_port + 10000.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if UI is running and responsive.
|
||||||
|
"""
|
||||||
|
if profile is None:
|
||||||
|
profile = resolve_active_profile()
|
||||||
|
return _manager.is_ui_running(profile, ui_port)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ui_url(profile: str | None = None, ui_port: int | None = None) -> str:
|
||||||
|
"""Get UI URL for a profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: Profile name (None = resolve from priority).
|
||||||
|
ui_port: Port for the UI. Defaults to daemon_port + 10000.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
URL for the UI.
|
||||||
|
"""
|
||||||
|
if profile is None:
|
||||||
|
profile = resolve_active_profile()
|
||||||
|
return _manager.get_ui_url(profile, ui_port)
|
||||||
|
|
||||||
|
|
||||||
def find_cli_binary() -> Path | None:
|
def find_cli_binary() -> Path | None:
|
||||||
"""Find the hindsight CLI binary in known locations or PATH."""
|
"""Find the hindsight CLI binary in known locations or PATH."""
|
||||||
import shutil
|
import shutil
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from rich.panel import Panel
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from .embed_manager import EmbedManager
|
from .embed_manager import EmbedManager
|
||||||
from .profile_manager import ProfileManager, resolve_active_profile
|
from .profile_manager import UI_PORT_OFFSET, ProfileManager, resolve_active_profile
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
console = Console(stderr=True)
|
console = Console(stderr=True)
|
||||||
|
|
@ -322,6 +322,219 @@ class DaemonEmbedManager(EmbedManager):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Failed to register profile '{profile}' in metadata: {e}")
|
logger.debug(f"Failed to register profile '{profile}' in metadata: {e}")
|
||||||
|
|
||||||
|
def _find_ui_command(self) -> list[str]:
|
||||||
|
"""Find the command to run the control plane UI."""
|
||||||
|
# Check if we're in development mode (monorepo)
|
||||||
|
dev_cp_path = Path(__file__).parent.parent.parent / "hindsight-control-plane"
|
||||||
|
cli_js = dev_cp_path / "bin" / "cli.js"
|
||||||
|
if cli_js.exists():
|
||||||
|
return ["node", str(cli_js)]
|
||||||
|
|
||||||
|
# Use npx to run the published control plane package
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
cp_version = os.getenv("HINDSIGHT_EMBED_CP_VERSION", __version__)
|
||||||
|
return ["npx", f"@vectorize-io/hindsight-control-plane@{cp_version}"]
|
||||||
|
|
||||||
|
def get_ui_url(self, profile: str, ui_port: int | None = None, hostname: str | None = None) -> str:
|
||||||
|
"""Get the URL for the UI serving this profile."""
|
||||||
|
if ui_port is None:
|
||||||
|
paths = self._profile_manager.resolve_profile_paths(profile)
|
||||||
|
ui_port = paths.port + UI_PORT_OFFSET
|
||||||
|
host = hostname or "0.0.0.0"
|
||||||
|
return f"http://{host}:{ui_port}"
|
||||||
|
|
||||||
|
def is_ui_running(self, profile: str, ui_port: int | None = None) -> bool:
|
||||||
|
"""Check if the UI is running and responsive."""
|
||||||
|
# Always health-check on 127.0.0.1 regardless of bind hostname
|
||||||
|
ui_url = self.get_ui_url(profile, ui_port, hostname="127.0.0.1")
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=2) as client:
|
||||||
|
response = client.get(f"{ui_url}/api/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start_ui(self, profile: str, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool:
|
||||||
|
"""Start the control plane UI in background.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: Profile name.
|
||||||
|
ui_port: Port for the UI. Defaults to daemon_port + 10000.
|
||||||
|
hostname: Hostname to bind to. Defaults to 0.0.0.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if UI started successfully.
|
||||||
|
"""
|
||||||
|
paths = self._profile_manager.resolve_profile_paths(profile)
|
||||||
|
if ui_port is None:
|
||||||
|
ui_port = paths.port + UI_PORT_OFFSET
|
||||||
|
|
||||||
|
if self.is_ui_running(profile, ui_port):
|
||||||
|
logger.debug(f"UI already running for profile '{profile}' on port {ui_port}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
profile_label = f"profile '{profile}'" if profile else "default profile"
|
||||||
|
api_url = self.get_url(profile)
|
||||||
|
ui_log = paths.ui_log
|
||||||
|
|
||||||
|
# Build environment
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PORT"] = str(ui_port)
|
||||||
|
env["HOSTNAME"] = hostname
|
||||||
|
env["HINDSIGHT_CP_DATAPLANE_API_URL"] = api_url
|
||||||
|
|
||||||
|
# Create log directory
|
||||||
|
ui_log.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
cmd = self._find_ui_command() + [
|
||||||
|
"--port",
|
||||||
|
str(ui_port),
|
||||||
|
"--hostname",
|
||||||
|
hostname,
|
||||||
|
"--api-url",
|
||||||
|
api_url,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_file = open(ui_log, "w")
|
||||||
|
subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
start_new_session=True,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=log_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for UI to be ready
|
||||||
|
start_time = time.time()
|
||||||
|
title = f"[bold cyan]Starting UI[/bold cyan] [dim]({profile or 'default'} @ :{ui_port})[/dim]"
|
||||||
|
log_lines = [f"Starting UI for {profile_label}...", ""]
|
||||||
|
|
||||||
|
with Live(console=console, auto_refresh=False) as live:
|
||||||
|
content = Text("\n".join(log_lines), style="dim")
|
||||||
|
panel = Panel(content, title=title, border_style="cyan", padding=(1, 2))
|
||||||
|
live.update(panel)
|
||||||
|
live.refresh()
|
||||||
|
|
||||||
|
while time.time() - start_time < 30:
|
||||||
|
if self.is_ui_running(profile, ui_port):
|
||||||
|
log_lines.append(f"✓ UI started at http://127.0.0.1:{ui_port}")
|
||||||
|
log_lines.append(f"Logs: {ui_log}")
|
||||||
|
content = Text("\n".join(log_lines), style="dim")
|
||||||
|
success_title = (
|
||||||
|
f"[bold green]✓ UI Started[/bold green] [dim]({profile or 'default'} @ :{ui_port})[/dim]"
|
||||||
|
)
|
||||||
|
panel = Panel(content, title=success_title, border_style="green", padding=(1, 2))
|
||||||
|
live.update(panel)
|
||||||
|
live.refresh()
|
||||||
|
console.print()
|
||||||
|
return True
|
||||||
|
|
||||||
|
elapsed = int(time.time() - start_time)
|
||||||
|
status_msg = f"⏳ Waiting for UI... ({elapsed}s elapsed)"
|
||||||
|
if log_lines and log_lines[-1].startswith("⏳"):
|
||||||
|
log_lines[-1] = status_msg
|
||||||
|
else:
|
||||||
|
log_lines.append(status_msg)
|
||||||
|
|
||||||
|
content = Text("\n".join(log_lines), style="dim")
|
||||||
|
panel = Panel(content, title=title, border_style="cyan", padding=(1, 2))
|
||||||
|
live.update(panel)
|
||||||
|
live.refresh()
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text(f"UI failed to start (timeout)\n\nSee full log: {ui_log}", style="dim"),
|
||||||
|
title=f"[bold red]✗ UI Failed (Timeout)[/bold red] [dim](:{ui_port})[/dim]",
|
||||||
|
border_style="red",
|
||||||
|
padding=(1, 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
return False
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
error_msg = (
|
||||||
|
f"Command not found: {cmd[0]}\nFull command: {' '.join(cmd)}\n\nInstall Node.js and npx to run the UI."
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text(error_msg, style="red"),
|
||||||
|
title="[bold red]✗ Command Not Found[/bold red]",
|
||||||
|
border_style="red",
|
||||||
|
padding=(1, 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to start UI: {e}\n\nCommand: {' '.join(cmd)}\nLog file: {ui_log}"
|
||||||
|
console.print(
|
||||||
|
Panel(
|
||||||
|
Text(error_msg, style="red"),
|
||||||
|
title="[bold red]✗ UI Startup Error[/bold red]",
|
||||||
|
border_style="red",
|
||||||
|
padding=(1, 2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_ui(self, profile: str, ui_port: int | None = None) -> bool:
|
||||||
|
"""Stop the UI for this profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: Profile name.
|
||||||
|
ui_port: Port the UI is running on. Defaults to daemon_port + 10000.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if stopped successfully.
|
||||||
|
"""
|
||||||
|
paths = self._profile_manager.resolve_profile_paths(profile)
|
||||||
|
if ui_port is None:
|
||||||
|
ui_port = paths.port + UI_PORT_OFFSET
|
||||||
|
|
||||||
|
if not self.is_ui_running(profile, ui_port):
|
||||||
|
logger.debug(f"UI not running for profile '{profile}'")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Find PID by port
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["lsof", "-ti", f":{ui_port}", "-sTCP:LISTEN"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
pid = int(result.stdout.strip().split()[0])
|
||||||
|
logger.debug(f"Found UI PID {pid} on port {ui_port}")
|
||||||
|
os.kill(pid, 15)
|
||||||
|
|
||||||
|
# Wait for process to exit
|
||||||
|
for _ in range(50):
|
||||||
|
time.sleep(0.1)
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.warning(f"Could not find PID for UI port {ui_port}")
|
||||||
|
except (subprocess.TimeoutExpired, ValueError, OSError, FileNotFoundError) as e:
|
||||||
|
logger.warning(f"Could not find/kill UI by port: {e}")
|
||||||
|
|
||||||
|
# Wait for health check to fail
|
||||||
|
for _ in range(30):
|
||||||
|
if not self.is_ui_running(profile, ui_port):
|
||||||
|
return True
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
return not self.is_ui_running(profile, ui_port)
|
||||||
|
|
||||||
def ensure_running(self, config: dict, profile: str) -> bool:
|
def ensure_running(self, config: dict, profile: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Ensure daemon is running, starting it if needed.
|
Ensure daemon is running, starting it if needed.
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ PROFILE_PORT_BASE = 8889
|
||||||
PROFILE_PORT_RANGE = 1000 # 8889-9888
|
PROFILE_PORT_RANGE = 1000 # 8889-9888
|
||||||
|
|
||||||
|
|
||||||
|
# UI port offset from daemon port (e.g., daemon 8888 -> UI 18888)
|
||||||
|
UI_PORT_OFFSET = 10000
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProfilePaths:
|
class ProfilePaths:
|
||||||
"""Paths and port for a profile."""
|
"""Paths and port for a profile."""
|
||||||
|
|
@ -36,6 +40,11 @@ class ProfilePaths:
|
||||||
lock: Path
|
lock: Path
|
||||||
log: Path
|
log: Path
|
||||||
port: int
|
port: int
|
||||||
|
ui_log: Path = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.ui_log is None:
|
||||||
|
self.ui_log = self.log.parent / self.log.name.replace(".log", ".ui.log")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue