Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Textual TUI prototype #508

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ dependencies = [
"opentelemetry-instrumentation-sqlite3==0.50b0",
"opentelemetry-exporter-otlp-proto-grpc==1.29.0",
"distro==1.9.0",
"textual>=1.0.0",
"msgpack>=1.1.0",
"tqdm>=4.67.1",
"aiofiles>=24.1.0",
]

# Local dependencies for development
Expand Down Expand Up @@ -66,6 +68,7 @@ dev-dependencies = [
"pytest-timeout>=2.3.1",
"pytest-xdist[psutil]>=3.6.1",
"pytest>=8.3.3",
"textual-dev>=1.7.0",
]

[tool.pytest.ini_options]
Expand Down
40 changes: 40 additions & 0 deletions syftbox/assets/tui.tcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.main {
width: 4fr;
padding-right: 1;
}

.info {
height: 1fr;
}

.syftbox-logs {
height: 1fr;
margin-top: 1;
background: $surface;
}

.status {
width: 1fr;
height: 100%;
background: $surface;
margin-right: 1;
}

.dim {
opacity: 0.6;
}

.sidebar {
margin-right: 1;
width: 1fr;
}

.api-logs {
width: 4fr;
height: 100%;
background: $surface;
}

.padding-top {
padding-top: 1;
}
2 changes: 0 additions & 2 deletions syftbox/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from typing import TYPE_CHECKING

import httpx
from loguru import logger
from typing_extensions import Protocol

from syftbox.client.exceptions import SyftAuthenticationError, SyftPermissionError, SyftServerError
Expand Down Expand Up @@ -102,7 +101,6 @@ def _make_headers(config: SyftClientConfig) -> dict[str, str]:
if config.access_token is not None:
headers["Authorization"] = f"Bearer {config.access_token}"

logger.debug(f"Server headers: {headers}")
return headers

@classmethod
Expand Down
4 changes: 4 additions & 0 deletions syftbox/client/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
from shutil import make_archive
from typing import Union

import loguru
from loguru import logger

from syftbox.lib.constants import DEFAULT_LOGS_DIR
from syftbox.lib.types import PathLike, to_path

LOGS_FORMAT = loguru


def setup_logger(
level: Union[str, int] = "DEBUG",
Expand All @@ -26,6 +29,7 @@ def setup_logger(
level="DEBUG",
rotation=None,
compression=None,
colorize=True,
)

# keep last 5 logs
Expand Down
51 changes: 31 additions & 20 deletions syftbox/client/routers/app_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pathlib import Path

import yaml
from aiofiles import open as aopen
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
Expand Down Expand Up @@ -164,26 +165,36 @@ async def app_command(ctx: APIContext, app_name: str, request: dict):


@router.get("/logs/{app_name}")
async def app_logs(ctx: APIContext, app_name: str):
async def app_logs(
ctx: APIContext,
app_name: str,
limit: int = 256,
offset: int = 0,
) -> JSONResponse:
apps_dir = ctx.workspace.apps
all_apps = get_all_apps(apps_dir)
app_details = None
for app in all_apps:
if app_name == app.name:
app_details = app

# Raise 404 if app not found
if app_details is None:
app_dir = Path(apps_dir) / app_name
if not app_dir.is_dir():
raise HTTPException(status_code=404, detail="App not found")

logs = []

# Read the log file if it exists
app_path = Path(app_details.path)

log_file = app_path / "logs" / f"{app_name}.log"
if log_file.exists():
with open(log_file, "r") as file:
logs = file.readlines()

return JSONResponse(content={"logs": logs})
logs: List[str] = []
log_file = app_dir / "logs" / f"{app_name}.log"
try:
if log_file.is_file():
async with aopen(log_file, "r") as file:
logs = await file.readlines()

# Calculate pagination indices
total_logs = len(logs)
start_idx = max(0, total_logs - offset - limit)
end_idx = total_logs - offset if offset > 0 else total_logs
logs = logs[start_idx:end_idx]

return JSONResponse(
content={
"logs": logs,
"total": total_logs,
"source": str(log_file),
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve logs: {str(e)}")
49 changes: 47 additions & 2 deletions syftbox/client/routers/index_router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi.responses import PlainTextResponse
from aiofiles import open as aopen
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse, PlainTextResponse

from syftbox import __version__
from syftbox.client.routers.common import APIContext
Expand All @@ -17,6 +18,50 @@ async def version():
return {"version": __version__}


@router.get("/logs")
async def get_logs(
context: APIContext,
limit: int = 256,
offset: int = 0,
) -> JSONResponse:
"""Get last log lines from the log file.

Args:
limit: Maximum number of log lines to return
offset: Number of lines to skip from the end of the log file
"""
logs_dir = context.workspace.data_dir / "logs"

try:
log_files = sorted(logs_dir.glob("syftbox_*.log"), reverse=True)
if not log_files:
return JSONResponse(
content={
"logs": [],
"total": 0,
}
)

last_log_file = log_files[0]
async with aopen(last_log_file, "r") as f:
content = await f.readlines()

total_logs = len(content)
start_idx = max(total_logs - offset - limit, 0)
end_idx = total_logs - offset if offset > 0 else total_logs

return JSONResponse(
content={
"logs": content[start_idx:end_idx],
"total": total_logs,
"source": str(last_log_file),
}
)

except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve logs: {str(e)}")


@router.get("/metadata")
async def metadata(ctx: APIContext):
return {"datasite": ctx.email}
22 changes: 20 additions & 2 deletions syftbox/client/routers/sync_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import wcmatch.glob
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from jinja2 import Environment, FileSystemLoader

from syftbox.client.exceptions import SyftPluginException
Expand Down Expand Up @@ -88,18 +88,36 @@ def filter_by_path_glob(items: List[SyncStatusInfo], pattern: Optional[str]) ->
return result


def apply_limit_offset(items: List[SyncStatusInfo], limit: Optional[int], offset: int) -> List[SyncStatusInfo]:
if offset:
items = items[offset:]
if limit:
items = items[:limit]
return items


@router.get("/health")
def health_check(sync_manager: SyncManager = Depends(get_sync_manager)) -> JSONResponse:
if not sync_manager.is_alive():
raise HTTPException(status_code=503, detail="Sync service unavailable")
return JSONResponse(content={"status": "ok"})


@router.get("/state")
def get_status_info(
order_by: str = "timestamp",
order: str = "desc",
path_glob: Optional[str] = None,
sync_manager: SyncManager = Depends(get_sync_manager),
limit: Optional[int] = None,
offset: int = 0,
) -> List[SyncStatusInfo]:
all_items = get_all_status_info(sync_manager)
items_deduplicated = deduplicate_status_info(all_items)
items_filtered = filter_by_path_glob(items_deduplicated, path_glob)
items_sorted = sort_status_info(items_filtered, order_by, order)
return items_sorted
items_paginated = apply_limit_offset(items_sorted, limit, offset)
return items_paginated


@router.get("/")
Expand Down
2 changes: 2 additions & 0 deletions syftbox/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from syftbox.app.cli import app as app_cli
from syftbox.client.cli import app as client_cli
from syftbox.server.cli import app as server_cli
from syftbox.tui.cli import app as tui_cli

app = Typer(
name="SyftBox",
Expand Down Expand Up @@ -44,6 +45,7 @@ def debug(config_path: Annotated[Optional[Path], CONFIG_OPTS] = None):
app.add_typer(client_cli, name="client")
app.add_typer(server_cli, name="server")
app.add_typer(app_cli, name="app")
app.add_typer(tui_cli, name="tui")


def main():
Expand Down
Empty file added syftbox/tui/__init__.py
Empty file.
54 changes: 54 additions & 0 deletions syftbox/tui/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from pathlib import Path

from textual.app import App
from textual.widgets import Footer, Header, TabbedContent, TabPane

from syftbox.lib import Client
from syftbox.tui.widgets.api_widget import APIWidget
from syftbox.tui.widgets.datasites_widget import DatasiteSelector
from syftbox.tui.widgets.home_widget import HomeWidget
from syftbox.tui.widgets.sync_widget import SyncWidget


class SyftBoxTUI(App):
CSS_PATH = Path(__file__).parent.parent / "assets" / "tui.tcss"
BINDINGS = [
("h", "switch_tab('Home')", "Home"),
("a", "switch_tab('APIs')", "APIs"),
("d", "switch_tab('Datasites')", "Datasites"),
("s", "switch_tab('Sync')", "Sync"),
]

def __init__(
self,
syftbox_context: Client,
):
super().__init__()
self.syftbox_context = syftbox_context

def action_switch_tab(self, tab: str) -> None:
self.query_one(TabbedContent).active = tab

def on_mount(self) -> None:
self.title = "SyftBox"

def compose(self):
yield Header(name="SyftBox")
with TabbedContent():
with TabPane("Home", id="Home"):
yield HomeWidget(self.syftbox_context)
with TabPane("APIs", id="APIs"):
yield APIWidget(self.syftbox_context)
with TabPane("Datasites", id="Datasites"):
yield DatasiteSelector(
base_path=self.syftbox_context.workspace.datasites,
default_datasite=self.syftbox_context.email,
)
with TabPane("Sync", id="Sync"):
yield SyncWidget(self.syftbox_context)
yield Footer()


# config = SyftClientConfig.load()
# syftbox_context = Client(config)
# app = SyftBoxTUI(syftbox_context)
55 changes: 55 additions & 0 deletions syftbox/tui/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from pathlib import Path
from typing import Annotated
from venv import logger

from rich import print as rprint
from typer import Exit, Option, Typer

from syftbox.lib.client_config import SyftClientConfig
from syftbox.lib.client_shim import Client
from syftbox.lib.constants import DEFAULT_CONFIG_PATH
from syftbox.lib.exceptions import ClientConfigException
from syftbox.tui.app import SyftBoxTUI

app = Typer(
name="SyftBox Terminal UI",
help="Launch the SyftBox Terminal UI",
pretty_exceptions_enable=False,
context_settings={"help_option_names": ["-h", "--help"]},
)

CONFIG_OPTS = Option("-c", "--config", "--config_path", help="Path to the SyftBox config")


@app.callback(invoke_without_command=True)
def run_tui(
config_path: Annotated[Path, CONFIG_OPTS] = DEFAULT_CONFIG_PATH,
) -> None:
syftbox_context = get_syftbox_context(config_path)
tui = SyftBoxTUI(syftbox_context)
logger.debug("Running SyftBox TUI")
tui.run()


def get_syftbox_context(config_path: Path) -> Client:
try:
conf = SyftClientConfig.load(config_path)
context = Client(conf)
return context
except ClientConfigException:
msg = (
f"[bold red]Error:[/bold red] Couldn't load config at: [yellow]'{config_path}'[/yellow]\n"
"Please ensure that:\n"
" - The configuration file exists at the specified path.\n"
" - You've run the SyftBox atleast once.\n"
f" - For custom configs, provide the proper path using [cyan]--config[/cyan] flag"
)
rprint(msg)
raise Exit(1)
except Exception as e:
rprint(f"[bold red]Error:[/bold red] {e}")
raise Exit(1)


if __name__ == "__main__":
app()
Empty file added syftbox/tui/widgets/__init__.py
Empty file.
Loading