logtap 0.3.0__py3-none-any.whl → 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- logtap/__init__.py +1 -1
- logtap/api/app.py +69 -3
- logtap/api/routes/health.py +26 -4
- logtap/api/routes/logs.py +26 -31
- logtap/api/routes/parsed.py +8 -7
- logtap/api/routes/runs.py +330 -0
- logtap/cli/commands/collect.py +107 -0
- logtap/cli/commands/doctor.py +127 -0
- logtap/cli/commands/ingest.py +123 -0
- logtap/cli/commands/runs.py +116 -0
- logtap/cli/commands/tail.py +220 -23
- logtap/cli/main.py +12 -5
- logtap/core/runs.py +433 -0
- logtap/core/validation.py +132 -0
- logtap/models/responses.py +54 -1
- logtap-0.4.1.dist-info/METADATA +304 -0
- {logtap-0.3.0.dist-info → logtap-0.4.1.dist-info}/RECORD +20 -14
- logtap-0.3.0.dist-info/METADATA +0 -319
- {logtap-0.3.0.dist-info → logtap-0.4.1.dist-info}/WHEEL +0 -0
- {logtap-0.3.0.dist-info → logtap-0.4.1.dist-info}/entry_points.txt +0 -0
- {logtap-0.3.0.dist-info → logtap-0.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Collector command for logtap CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def collect(
|
|
13
|
+
port: int = typer.Option(
|
|
14
|
+
8000,
|
|
15
|
+
"--port",
|
|
16
|
+
"-p",
|
|
17
|
+
help="Port to listen on.",
|
|
18
|
+
),
|
|
19
|
+
host: str = typer.Option(
|
|
20
|
+
"0.0.0.0",
|
|
21
|
+
"--host",
|
|
22
|
+
"-H",
|
|
23
|
+
help="Host to bind to.",
|
|
24
|
+
),
|
|
25
|
+
api_key: Optional[str] = typer.Option(
|
|
26
|
+
None,
|
|
27
|
+
"--api-key",
|
|
28
|
+
"-k",
|
|
29
|
+
help="API key for authentication.",
|
|
30
|
+
envvar="LOGTAP_API_KEY",
|
|
31
|
+
),
|
|
32
|
+
data_dir: Path = typer.Option(
|
|
33
|
+
Path("~/.logtap/runs").expanduser(),
|
|
34
|
+
"--data-dir",
|
|
35
|
+
"-d",
|
|
36
|
+
help="Directory for run storage.",
|
|
37
|
+
),
|
|
38
|
+
buffer_lines: int = typer.Option(
|
|
39
|
+
100_000,
|
|
40
|
+
"--buffer-lines",
|
|
41
|
+
help="In-memory cache size per run.",
|
|
42
|
+
),
|
|
43
|
+
max_disk_mb: int = typer.Option(
|
|
44
|
+
1000,
|
|
45
|
+
"--max-disk-mb",
|
|
46
|
+
help="Maximum disk usage across all runs (MB).",
|
|
47
|
+
),
|
|
48
|
+
retention_hours: int = typer.Option(
|
|
49
|
+
72,
|
|
50
|
+
"--retention-hours",
|
|
51
|
+
help="Hours to retain runs before cleanup.",
|
|
52
|
+
),
|
|
53
|
+
reload: bool = typer.Option(
|
|
54
|
+
False,
|
|
55
|
+
"--reload",
|
|
56
|
+
"-r",
|
|
57
|
+
help="Enable auto-reload for development.",
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Start the logtap collector server.
|
|
62
|
+
|
|
63
|
+
Accepts ingested log streams over HTTP and serves them for tailing.
|
|
64
|
+
This is the recommended mode for ML training logs.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
logtap collect
|
|
68
|
+
logtap collect --port 9000 --api-key secret
|
|
69
|
+
logtap collect --data-dir /mnt/logs --max-disk-mb 5000
|
|
70
|
+
"""
|
|
71
|
+
import os
|
|
72
|
+
|
|
73
|
+
import uvicorn
|
|
74
|
+
|
|
75
|
+
# Expand data_dir
|
|
76
|
+
data_dir = Path(data_dir).expanduser()
|
|
77
|
+
|
|
78
|
+
# Set environment variables for the app
|
|
79
|
+
os.environ["LOGTAP_HOST"] = host
|
|
80
|
+
os.environ["LOGTAP_PORT"] = str(port)
|
|
81
|
+
os.environ["LOGTAP_MODE"] = "collect"
|
|
82
|
+
os.environ["LOGTAP_DATA_DIR"] = str(data_dir)
|
|
83
|
+
os.environ["LOGTAP_BUFFER_LINES"] = str(buffer_lines)
|
|
84
|
+
os.environ["LOGTAP_MAX_DISK_MB"] = str(max_disk_mb)
|
|
85
|
+
os.environ["LOGTAP_RETENTION_HOURS"] = str(retention_hours)
|
|
86
|
+
if api_key:
|
|
87
|
+
os.environ["LOGTAP_API_KEY"] = api_key
|
|
88
|
+
|
|
89
|
+
console.print("[bold green]Starting logtap collector[/bold green]")
|
|
90
|
+
console.print(f" [dim]Host:[/dim] {host}")
|
|
91
|
+
console.print(f" [dim]Port:[/dim] {port}")
|
|
92
|
+
console.print(f" [dim]Data directory:[/dim] {data_dir}")
|
|
93
|
+
console.print(f" [dim]Buffer lines:[/dim] {buffer_lines:,}")
|
|
94
|
+
console.print(f" [dim]Max disk:[/dim] {max_disk_mb} MB")
|
|
95
|
+
console.print(f" [dim]Retention:[/dim] {retention_hours} hours")
|
|
96
|
+
console.print(f" [dim]Auth:[/dim] {'enabled' if api_key else 'disabled'}")
|
|
97
|
+
console.print()
|
|
98
|
+
console.print(f"[dim]API docs available at[/dim] http://{host}:{port}/docs")
|
|
99
|
+
console.print()
|
|
100
|
+
|
|
101
|
+
uvicorn.run(
|
|
102
|
+
"logtap.api.app:create_collector_app",
|
|
103
|
+
host=host,
|
|
104
|
+
port=port,
|
|
105
|
+
reload=reload,
|
|
106
|
+
factory=True,
|
|
107
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Doctor command for logtap CLI - diagnose connection issues."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def doctor(
|
|
12
|
+
server: str = typer.Option(
|
|
13
|
+
"http://localhost:8000",
|
|
14
|
+
"--server",
|
|
15
|
+
"-s",
|
|
16
|
+
help="Server URL to check.",
|
|
17
|
+
envvar="LOGTAP_SERVER",
|
|
18
|
+
),
|
|
19
|
+
api_key: Optional[str] = typer.Option(
|
|
20
|
+
None,
|
|
21
|
+
"--api-key",
|
|
22
|
+
"-k",
|
|
23
|
+
help="API key for authentication.",
|
|
24
|
+
envvar="LOGTAP_API_KEY",
|
|
25
|
+
),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Check server connectivity and diagnose issues.
|
|
29
|
+
|
|
30
|
+
Verifies the server is reachable, auth works, and reports capabilities.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
logtap doctor
|
|
34
|
+
logtap doctor --server http://gpu-box:8000
|
|
35
|
+
"""
|
|
36
|
+
import httpx
|
|
37
|
+
|
|
38
|
+
console.print(f"[bold]Checking {server}[/bold]\n")
|
|
39
|
+
|
|
40
|
+
# Build headers
|
|
41
|
+
headers = {}
|
|
42
|
+
if api_key:
|
|
43
|
+
headers["X-API-Key"] = api_key
|
|
44
|
+
|
|
45
|
+
# Step 1: Check if server is reachable
|
|
46
|
+
console.print("[dim]1. Server reachable?[/dim]", end=" ")
|
|
47
|
+
try:
|
|
48
|
+
with httpx.Client(timeout=10) as client:
|
|
49
|
+
response = client.get(f"{server}/health", headers=headers)
|
|
50
|
+
except httpx.ConnectError:
|
|
51
|
+
console.print("[red]NO[/red]")
|
|
52
|
+
console.print(f"\n[red]Could not connect to {server}[/red]")
|
|
53
|
+
console.print("\n[dim]Possible causes:[/dim]")
|
|
54
|
+
console.print(" - Server not running (start with: logtap collect)")
|
|
55
|
+
console.print(" - Wrong host/port")
|
|
56
|
+
console.print(" - Firewall blocking connection")
|
|
57
|
+
console.print(f"\n[dim]Try:[/dim] curl {server}/health")
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
except httpx.TimeoutException:
|
|
60
|
+
console.print("[red]TIMEOUT[/red]")
|
|
61
|
+
console.print(f"\n[red]Connection timed out to {server}[/red]")
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
console.print("[green]YES[/green]")
|
|
65
|
+
|
|
66
|
+
# Step 2: Check auth
|
|
67
|
+
console.print("[dim]2. Auth working?[/dim]", end=" ")
|
|
68
|
+
if response.status_code == 401:
|
|
69
|
+
console.print("[red]NO (401 Unauthorized)[/red]")
|
|
70
|
+
console.print("\n[red]API key required or invalid[/red]")
|
|
71
|
+
if not api_key:
|
|
72
|
+
console.print("\n[dim]Try:[/dim] logtap doctor --api-key YOUR_KEY")
|
|
73
|
+
console.print("[dim]Or:[/dim] export LOGTAP_API_KEY=YOUR_KEY")
|
|
74
|
+
else:
|
|
75
|
+
console.print("\n[dim]Check that your API key matches the server's --api-key[/dim]")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
elif response.status_code >= 400:
|
|
78
|
+
console.print(f"[red]NO ({response.status_code})[/red]")
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
|
|
81
|
+
if api_key:
|
|
82
|
+
console.print("[green]YES (key accepted)[/green]")
|
|
83
|
+
else:
|
|
84
|
+
console.print("[green]YES (no auth required)[/green]")
|
|
85
|
+
|
|
86
|
+
# Step 3: Parse health response
|
|
87
|
+
try:
|
|
88
|
+
health = response.json()
|
|
89
|
+
except Exception:
|
|
90
|
+
console.print("\n[yellow]Warning: Could not parse health response[/yellow]")
|
|
91
|
+
health = {}
|
|
92
|
+
|
|
93
|
+
# Step 4: Check features
|
|
94
|
+
console.print("[dim]3. Features?[/dim]", end=" ")
|
|
95
|
+
mode = health.get("mode", "unknown")
|
|
96
|
+
features = health.get("features", [])
|
|
97
|
+
version = health.get("version", "unknown")
|
|
98
|
+
|
|
99
|
+
if "runs" in features:
|
|
100
|
+
console.print("[green]runs[/green] (collector mode)")
|
|
101
|
+
elif "files" in features:
|
|
102
|
+
console.print("[cyan]files[/cyan] (legacy serve mode)")
|
|
103
|
+
else:
|
|
104
|
+
console.print(f"[yellow]{mode}[/yellow]")
|
|
105
|
+
|
|
106
|
+
# Step 5: Show summary
|
|
107
|
+
console.print("\n[bold green]Server OK[/bold green]")
|
|
108
|
+
console.print(f" Version: {version}")
|
|
109
|
+
console.print(f" Mode: {mode}")
|
|
110
|
+
console.print(f" Features: {', '.join(features) if features else 'unknown'}")
|
|
111
|
+
|
|
112
|
+
if "runs" in features:
|
|
113
|
+
runs_count = health.get("runs", 0)
|
|
114
|
+
console.print(f" Active runs: {runs_count}")
|
|
115
|
+
|
|
116
|
+
# Show curl command for debugging
|
|
117
|
+
console.print("\n[dim]Debug command:[/dim]")
|
|
118
|
+
if api_key:
|
|
119
|
+
console.print(f' curl -H "X-API-Key: {api_key}" {server}/health')
|
|
120
|
+
else:
|
|
121
|
+
console.print(f" curl {server}/health")
|
|
122
|
+
|
|
123
|
+
# Show quick start if collector mode
|
|
124
|
+
if "runs" in features:
|
|
125
|
+
console.print("\n[dim]Quick start:[/dim]")
|
|
126
|
+
console.print(" python train.py 2>&1 | logtap ingest run1")
|
|
127
|
+
console.print(" logtap tail run1 --follow")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Ingest command for logtap CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
console = Console(stderr=True) # Output status to stderr so it doesn't mix with piped data
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ingest(
|
|
14
|
+
name: Optional[str] = typer.Argument(
|
|
15
|
+
None,
|
|
16
|
+
help="Run name. If omitted, generates: run-YYYYMMDD-HHMMSS",
|
|
17
|
+
),
|
|
18
|
+
server: str = typer.Option(
|
|
19
|
+
"http://localhost:8000",
|
|
20
|
+
"--server",
|
|
21
|
+
"-s",
|
|
22
|
+
help="Collector server URL.",
|
|
23
|
+
envvar="LOGTAP_SERVER",
|
|
24
|
+
),
|
|
25
|
+
api_key: Optional[str] = typer.Option(
|
|
26
|
+
None,
|
|
27
|
+
"--api-key",
|
|
28
|
+
"-k",
|
|
29
|
+
help="API key for authentication.",
|
|
30
|
+
envvar="LOGTAP_API_KEY",
|
|
31
|
+
),
|
|
32
|
+
tag: Optional[List[str]] = typer.Option(
|
|
33
|
+
None,
|
|
34
|
+
"--tag",
|
|
35
|
+
"-t",
|
|
36
|
+
help="Tags as key=value (repeatable).",
|
|
37
|
+
),
|
|
38
|
+
quiet: bool = typer.Option(
|
|
39
|
+
False,
|
|
40
|
+
"--quiet",
|
|
41
|
+
"-q",
|
|
42
|
+
help="Suppress status messages.",
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Pipe stdin to collector as a named run.
|
|
47
|
+
|
|
48
|
+
Reads from stdin and streams lines to the collector server.
|
|
49
|
+
Designed to be used with pipes for training logs.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
python train.py 2>&1 | logtap ingest run1
|
|
53
|
+
python train.py 2>&1 | logtap ingest --tag node=gpu1
|
|
54
|
+
cat training.log | logtap ingest my-run --server http://collector:8000
|
|
55
|
+
"""
|
|
56
|
+
import httpx
|
|
57
|
+
|
|
58
|
+
# Generate run name if not provided
|
|
59
|
+
if name is None:
|
|
60
|
+
name = datetime.now().strftime("run-%Y%m%d-%H%M%S")
|
|
61
|
+
|
|
62
|
+
# Build headers as list of tuples (supports duplicate keys)
|
|
63
|
+
headers: List[tuple] = [("Content-Type", "text/plain")]
|
|
64
|
+
if api_key:
|
|
65
|
+
headers.append(("X-API-Key", api_key))
|
|
66
|
+
if tag:
|
|
67
|
+
for t in tag:
|
|
68
|
+
headers.append(("X-Logtap-Tag", t))
|
|
69
|
+
|
|
70
|
+
url = f"{server.rstrip('/')}/runs/{name}/ingest"
|
|
71
|
+
|
|
72
|
+
if not quiet:
|
|
73
|
+
console.print(f"[dim]Ingesting to[/dim] {name} [dim]@[/dim] {server}")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Read all stdin
|
|
77
|
+
content = sys.stdin.read()
|
|
78
|
+
|
|
79
|
+
if not quiet:
|
|
80
|
+
line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
|
|
81
|
+
console.print(f"[dim]Sending {line_count} lines...[/dim]")
|
|
82
|
+
|
|
83
|
+
# Use httpx
|
|
84
|
+
with httpx.Client(timeout=None) as client:
|
|
85
|
+
response = client.post(url, content=content, headers=headers)
|
|
86
|
+
|
|
87
|
+
if response.status_code == 401:
|
|
88
|
+
console.print("[red]Error: Unauthorized. Check your API key.[/red]")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
elif response.status_code == 409:
|
|
91
|
+
try:
|
|
92
|
+
data = response.json()
|
|
93
|
+
msg = data.get("detail", {}).get("message", "Tag conflict")
|
|
94
|
+
except Exception:
|
|
95
|
+
msg = "Tag conflict"
|
|
96
|
+
console.print(f"[red]Error: {msg}[/red]")
|
|
97
|
+
raise typer.Exit(1)
|
|
98
|
+
elif response.status_code == 507:
|
|
99
|
+
console.print("[red]Error: Server storage limit exceeded.[/red]")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
elif response.status_code >= 400:
|
|
102
|
+
console.print(f"[red]Error: Server returned {response.status_code}[/red]")
|
|
103
|
+
try:
|
|
104
|
+
console.print(f"[dim]{response.text}[/dim]")
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
raise typer.Exit(1)
|
|
108
|
+
|
|
109
|
+
data = response.json()
|
|
110
|
+
if not quiet:
|
|
111
|
+
console.print(
|
|
112
|
+
f"[green]Done.[/green] "
|
|
113
|
+
f"Ingested {data['lines_ingested']} lines to [bold]{data['run_id']}[/bold] "
|
|
114
|
+
f"(cursor: {data['cursor_end']})"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
except httpx.ConnectError:
|
|
118
|
+
console.print(f"[red]Error: Could not connect to {server}[/red]")
|
|
119
|
+
console.print("[dim]Is the collector running? Start with: logtap collect[/dim]")
|
|
120
|
+
raise typer.Exit(1)
|
|
121
|
+
except httpx.TimeoutException:
|
|
122
|
+
console.print("[red]Error: Connection timed out[/red]")
|
|
123
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Runs command for logtap CLI."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def runs(
|
|
13
|
+
server: str = typer.Option(
|
|
14
|
+
"http://localhost:8000",
|
|
15
|
+
"--server",
|
|
16
|
+
"-s",
|
|
17
|
+
help="Collector server URL.",
|
|
18
|
+
envvar="LOGTAP_SERVER",
|
|
19
|
+
),
|
|
20
|
+
api_key: Optional[str] = typer.Option(
|
|
21
|
+
None,
|
|
22
|
+
"--api-key",
|
|
23
|
+
"-k",
|
|
24
|
+
help="API key for authentication.",
|
|
25
|
+
envvar="LOGTAP_API_KEY",
|
|
26
|
+
),
|
|
27
|
+
since_hours: Optional[int] = typer.Option(
|
|
28
|
+
24,
|
|
29
|
+
"--since-hours",
|
|
30
|
+
help="Show runs active within N hours.",
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
List runs on a collector.
|
|
35
|
+
|
|
36
|
+
Shows all active and recent runs with their line counts and tags.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
logtap runs
|
|
40
|
+
logtap runs --server http://gpu-box:8000
|
|
41
|
+
logtap runs --since-hours 48
|
|
42
|
+
"""
|
|
43
|
+
from datetime import datetime, timezone
|
|
44
|
+
|
|
45
|
+
import httpx
|
|
46
|
+
|
|
47
|
+
# Build headers
|
|
48
|
+
headers = {}
|
|
49
|
+
if api_key:
|
|
50
|
+
headers["X-API-Key"] = api_key
|
|
51
|
+
|
|
52
|
+
url = f"{server.rstrip('/')}/runs"
|
|
53
|
+
params = {}
|
|
54
|
+
if since_hours is not None:
|
|
55
|
+
params["since_hours"] = since_hours
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with httpx.Client(timeout=30) as client:
|
|
59
|
+
response = client.get(url, headers=headers, params=params)
|
|
60
|
+
|
|
61
|
+
if response.status_code == 401:
|
|
62
|
+
console.print("[red]Error: Unauthorized. Check your API key.[/red]")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
elif response.status_code >= 400:
|
|
65
|
+
console.print(f"[red]Error: Server returned {response.status_code}[/red]")
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
|
|
68
|
+
data = response.json()
|
|
69
|
+
runs_list = data.get("runs", [])
|
|
70
|
+
|
|
71
|
+
if not runs_list:
|
|
72
|
+
console.print("[dim]No runs found.[/dim]")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Create table
|
|
76
|
+
table = Table(show_header=True, header_style="bold")
|
|
77
|
+
table.add_column("RUN", style="cyan")
|
|
78
|
+
table.add_column("LINES", justify="right")
|
|
79
|
+
table.add_column("LAST ACTIVITY", style="dim")
|
|
80
|
+
table.add_column("TAGS")
|
|
81
|
+
|
|
82
|
+
now = datetime.now(timezone.utc)
|
|
83
|
+
|
|
84
|
+
for run in runs_list:
|
|
85
|
+
# Format last activity as relative time
|
|
86
|
+
last_activity = datetime.fromisoformat(run["last_activity"].replace("Z", "+00:00"))
|
|
87
|
+
delta = now - last_activity
|
|
88
|
+
if delta.total_seconds() < 60:
|
|
89
|
+
time_str = f"{int(delta.total_seconds())}s ago"
|
|
90
|
+
elif delta.total_seconds() < 3600:
|
|
91
|
+
time_str = f"{int(delta.total_seconds() / 60)}m ago"
|
|
92
|
+
elif delta.total_seconds() < 86400:
|
|
93
|
+
time_str = f"{int(delta.total_seconds() / 3600)}h ago"
|
|
94
|
+
else:
|
|
95
|
+
time_str = f"{int(delta.total_seconds() / 86400)}d ago"
|
|
96
|
+
|
|
97
|
+
# Format tags
|
|
98
|
+
tags = run.get("tags", {})
|
|
99
|
+
tags_str = ", ".join(f"{k}={v}" for k, v in tags.items()) if tags else ""
|
|
100
|
+
|
|
101
|
+
table.add_row(
|
|
102
|
+
run["id"],
|
|
103
|
+
f"{run['lines']:,}",
|
|
104
|
+
time_str,
|
|
105
|
+
tags_str,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
console.print(table)
|
|
109
|
+
|
|
110
|
+
except httpx.ConnectError:
|
|
111
|
+
console.print(f"[red]Error: Could not connect to {server}[/red]")
|
|
112
|
+
console.print("[dim]Is the collector running? Start with: logtap collect[/dim]")
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
except httpx.TimeoutException:
|
|
115
|
+
console.print("[red]Error: Connection timed out[/red]")
|
|
116
|
+
raise typer.Exit(1)
|