mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(cli): replace all 18 stub commands with real API implementations
Every service command (vector, kv, search, doc, events, pubsub, queues, jobs, secrets, tasks, fn, auto, flow, ml, o11y, cx, growth, platform) now makes authenticated HTTP calls via shared service_request/check_response utilities in base.py. No stubs, no hardcoded data, no placeholders. Bump hanzo 0.4.0→0.4.1, hanzo-cli 0.2.0→0.2.1.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "Hanzo unified CLI — IAM, KMS, Deploy"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzo"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
description = "Hanzo AI - Complete AI Infrastructure Platform with CLI, Router, MCP, and Agent Runtime"
|
||||
authors = [
|
||||
{name = "Hanzo AI", email = "[email protected]"},
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Based on Activepieces with 280+ integrations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
AUTO_URL = os.getenv("HANZO_AUTO_URL", "https://auto.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(AUTO_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="auto")
|
||||
@@ -58,6 +69,14 @@ def flows():
|
||||
@click.option("--status", type=click.Choice(["active", "inactive", "all"]), default="all")
|
||||
def flows_list(status: str):
|
||||
"""List all automation flows."""
|
||||
params = {}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
|
||||
resp = _request("get", "/v1/flows", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("flows", data.get("items", []))
|
||||
|
||||
table = Table(title="Automation Flows", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Status", style="green")
|
||||
@@ -65,8 +84,20 @@ def flows_list(status: str):
|
||||
table.add_column("Last Run", style="dim")
|
||||
table.add_column("Runs", style="dim")
|
||||
|
||||
for f in items:
|
||||
f_status = f.get("status", "inactive")
|
||||
style = "green" if f_status == "active" else "yellow"
|
||||
table.add_row(
|
||||
f.get("name", ""),
|
||||
f"[{style}]{f_status}[/{style}]",
|
||||
f.get("trigger_type", "-"),
|
||||
str(f.get("last_run_at", ""))[:19],
|
||||
str(f.get("run_count", 0)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No flows found. Create one with 'hanzo auto flows create'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No flows found. Create one with 'hanzo auto flows create'[/dim]")
|
||||
|
||||
|
||||
@flows.command(name="create")
|
||||
@@ -74,8 +105,13 @@ def flows_list(status: str):
|
||||
@click.option("--trigger", "-t", type=click.Choice(["webhook", "schedule", "manual"]), default="manual")
|
||||
def flows_create(name: str, trigger: str):
|
||||
"""Create a new automation flow."""
|
||||
console.print(f"[cyan]Creating flow '{name}'...[/cyan]")
|
||||
body = {"name": name, "trigger_type": trigger}
|
||||
|
||||
resp = _request("post", "/v1/flows", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Flow '{name}' created with {trigger} trigger")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print()
|
||||
console.print("Next steps:")
|
||||
console.print(" 1. [cyan]hanzo auto dev[/cyan] - Start visual editor")
|
||||
@@ -88,8 +124,20 @@ def flows_create(name: str, trigger: str):
|
||||
@click.option("--input", "-i", help="JSON input for the flow")
|
||||
def flows_run(flow_name: str, input: str):
|
||||
"""Run an automation flow."""
|
||||
body = {}
|
||||
if input:
|
||||
body["input"] = json.loads(input)
|
||||
|
||||
console.print(f"[cyan]Running flow '{flow_name}'...[/cyan]")
|
||||
console.print("[green]✓[/green] Flow completed successfully")
|
||||
resp = _request("post", f"/v1/flows/{flow_name}/run", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
run_status = data.get("status", "completed")
|
||||
style = "green" if run_status in ("completed", "success") else "red"
|
||||
console.print(f"[{style}]✓[/{style}] Flow {run_status}")
|
||||
console.print(f" Run ID: {data.get('id', data.get('run_id', '-'))}")
|
||||
if data.get("duration_ms"):
|
||||
console.print(f" Duration: {data['duration_ms']}ms")
|
||||
|
||||
|
||||
@flows.command(name="delete")
|
||||
@@ -100,6 +148,9 @@ def flows_delete(flow_name: str):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete flow '{flow_name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/flows/{flow_name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flow '{flow_name}' deleted")
|
||||
|
||||
|
||||
@@ -107,6 +158,8 @@ def flows_delete(flow_name: str):
|
||||
@click.argument("flow_name")
|
||||
def flows_enable(flow_name: str):
|
||||
"""Enable an automation flow."""
|
||||
resp = _request("post", f"/v1/flows/{flow_name}/enable")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flow '{flow_name}' enabled")
|
||||
|
||||
|
||||
@@ -114,6 +167,8 @@ def flows_enable(flow_name: str):
|
||||
@click.argument("flow_name")
|
||||
def flows_disable(flow_name: str):
|
||||
"""Disable an automation flow."""
|
||||
resp = _request("post", f"/v1/flows/{flow_name}/disable")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flow '{flow_name}' disabled")
|
||||
|
||||
|
||||
@@ -133,31 +188,33 @@ def pieces():
|
||||
@click.option("--search", "-s", help="Search pieces")
|
||||
def pieces_list(category: str, search: str):
|
||||
"""List available pieces."""
|
||||
table = Table(title="Available Pieces (280+)", box=box.ROUNDED)
|
||||
params = {}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if search:
|
||||
params["search"] = search
|
||||
|
||||
resp = _request("get", "/v1/pieces", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("pieces", data.get("items", []))
|
||||
|
||||
table = Table(title="Available Pieces", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Category", style="white")
|
||||
table.add_column("Version", style="dim")
|
||||
table.add_column("Installed", style="green")
|
||||
|
||||
pieces_data = [
|
||||
("Gmail", "Email", "0.3.0", "✓"),
|
||||
("Slack", "Communication", "0.4.0", "✓"),
|
||||
("GitHub", "Development", "0.5.0", "✓"),
|
||||
("OpenAI", "AI", "0.6.0", "✓"),
|
||||
("Anthropic", "AI", "0.2.0", ""),
|
||||
("Google Sheets", "Productivity", "0.3.0", ""),
|
||||
("Stripe", "Payments", "0.4.0", ""),
|
||||
("HubSpot", "CRM", "0.2.0", ""),
|
||||
]
|
||||
|
||||
for name, cat, version, installed in pieces_data:
|
||||
if category and cat.lower() != category.lower():
|
||||
continue
|
||||
if search and search.lower() not in name.lower():
|
||||
continue
|
||||
table.add_row(name, cat, version, installed)
|
||||
for p in items:
|
||||
table.add_row(
|
||||
p.get("name", ""),
|
||||
p.get("category", "-"),
|
||||
p.get("version", "-"),
|
||||
"✓" if p.get("installed") else "",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No pieces found[/dim]")
|
||||
|
||||
|
||||
@pieces.command(name="install")
|
||||
@@ -165,6 +222,8 @@ def pieces_list(category: str, search: str):
|
||||
def pieces_install(piece_name: str):
|
||||
"""Install a piece."""
|
||||
console.print(f"[cyan]Installing piece '{piece_name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/pieces/{piece_name}/install")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Piece '{piece_name}' installed")
|
||||
|
||||
|
||||
@@ -172,6 +231,8 @@ def pieces_install(piece_name: str):
|
||||
@click.argument("piece_name")
|
||||
def pieces_uninstall(piece_name: str):
|
||||
"""Uninstall a piece."""
|
||||
resp = _request("post", f"/v1/pieces/{piece_name}/uninstall")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Piece '{piece_name}' uninstalled")
|
||||
|
||||
|
||||
@@ -189,14 +250,29 @@ def connections():
|
||||
@connections.command(name="list")
|
||||
def connections_list():
|
||||
"""List all connections."""
|
||||
resp = _request("get", "/v1/connections")
|
||||
data = check_response(resp)
|
||||
items = data.get("connections", data.get("items", []))
|
||||
|
||||
table = Table(title="Connections", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Piece", style="white")
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for c in items:
|
||||
c_status = c.get("status", "active")
|
||||
style = "green" if c_status == "active" else "yellow"
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
c.get("piece", "-"),
|
||||
f"[{style}]{c_status}[/{style}]",
|
||||
str(c.get("created_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No connections found. Add one with 'hanzo auto connections add'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No connections found. Add one with 'hanzo auto connections add'[/dim]")
|
||||
|
||||
|
||||
@connections.command(name="add")
|
||||
@@ -204,15 +280,26 @@ def connections_list():
|
||||
@click.option("--name", "-n", help="Connection name")
|
||||
def connections_add(piece_name: str, name: str):
|
||||
"""Add a new connection."""
|
||||
console.print(f"[cyan]Adding connection for '{piece_name}'...[/cyan]")
|
||||
console.print("Opening browser for OAuth authentication...")
|
||||
body = {"piece": piece_name}
|
||||
if name:
|
||||
body["name"] = name
|
||||
|
||||
resp = _request("post", "/v1/connections", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
auth_url = data.get("auth_url")
|
||||
if auth_url:
|
||||
console.print(f"[cyan]Authenticate at:[/cyan] {auth_url}")
|
||||
console.print(f"[green]✓[/green] Connection added")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
|
||||
|
||||
@connections.command(name="delete")
|
||||
@click.argument("connection_name")
|
||||
def connections_delete(connection_name: str):
|
||||
"""Delete a connection."""
|
||||
resp = _request("delete", f"/v1/connections/{connection_name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Connection '{connection_name}' deleted")
|
||||
|
||||
|
||||
@@ -257,15 +344,19 @@ def dev(port: int):
|
||||
def deploy(flow_name: str, deploy_all: bool):
|
||||
"""Deploy flows to production."""
|
||||
if not flow_name and not deploy_all:
|
||||
console.print("[yellow]Specify a flow name or use --all[/yellow]")
|
||||
return
|
||||
raise click.ClickException("Specify a flow name or use --all")
|
||||
|
||||
body = {}
|
||||
if deploy_all:
|
||||
body["all"] = True
|
||||
console.print("[cyan]Deploying all flows...[/cyan]")
|
||||
else:
|
||||
body["flow"] = flow_name
|
||||
console.print(f"[cyan]Deploying flow '{flow_name}'...[/cyan]")
|
||||
|
||||
console.print("[green]✓[/green] Deployed successfully")
|
||||
resp = _request("post", "/v1/flows/deploy", json=body)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Deployed {data.get('deployed', 1)} flow(s)")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -285,6 +376,16 @@ def runs():
|
||||
@click.option("--limit", "-n", default=50, help="Max results")
|
||||
def runs_list(flow: str, status: str, limit: int):
|
||||
"""List flow runs."""
|
||||
params = {"limit": limit}
|
||||
if flow:
|
||||
params["flow"] = flow
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
|
||||
resp = _request("get", "/v1/runs", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("runs", data.get("items", []))
|
||||
|
||||
table = Table(title="Flow Runs", box=box.ROUNDED)
|
||||
table.add_column("Run ID", style="cyan")
|
||||
table.add_column("Flow", style="white")
|
||||
@@ -292,43 +393,91 @@ def runs_list(flow: str, status: str, limit: int):
|
||||
table.add_column("Duration", style="yellow")
|
||||
table.add_column("Started", style="dim")
|
||||
|
||||
for r in items:
|
||||
r_status = r.get("status", "unknown")
|
||||
status_style = {
|
||||
"success": "green",
|
||||
"completed": "green",
|
||||
"failed": "red",
|
||||
"running": "cyan",
|
||||
}.get(r_status, "white")
|
||||
|
||||
table.add_row(
|
||||
str(r.get("id", ""))[:16],
|
||||
r.get("flow", "-"),
|
||||
f"[{status_style}]{r_status}[/{status_style}]",
|
||||
f"{r.get('duration_ms', '-')}ms" if r.get("duration_ms") else "-",
|
||||
str(r.get("started_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No runs found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No runs found[/dim]")
|
||||
|
||||
|
||||
@runs.command(name="show")
|
||||
@click.argument("run_id")
|
||||
def runs_show(run_id: str):
|
||||
"""Show run details."""
|
||||
resp = _request("get", f"/v1/runs/{run_id}")
|
||||
data = check_response(resp)
|
||||
|
||||
run_status = data.get("status", "unknown")
|
||||
status_style = "green" if run_status in ("success", "completed") else "red" if run_status == "failed" else "cyan"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Run ID:[/cyan] {run_id}\n"
|
||||
f"[cyan]Flow:[/cyan] my-flow\n"
|
||||
f"[cyan]Status:[/cyan] [green]● Success[/green]\n"
|
||||
f"[cyan]Duration:[/cyan] 2.3s\n"
|
||||
f"[cyan]Steps:[/cyan] 5\n"
|
||||
f"[cyan]Started:[/cyan] 2024-01-15 10:30:00",
|
||||
f"[cyan]Run ID:[/cyan] {data.get('id', run_id)}\n"
|
||||
f"[cyan]Flow:[/cyan] {data.get('flow', '-')}\n"
|
||||
f"[cyan]Status:[/cyan] [{status_style}]{run_status}[/{status_style}]\n"
|
||||
f"[cyan]Duration:[/cyan] {data.get('duration_ms', '-')}ms\n"
|
||||
f"[cyan]Steps:[/cyan] {data.get('step_count', 0)}\n"
|
||||
f"[cyan]Started:[/cyan] {str(data.get('started_at', ''))[:19]}",
|
||||
title="Run Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
if data.get("error"):
|
||||
console.print(f"\n[red]Error:[/red] {data['error']}")
|
||||
|
||||
|
||||
@runs.command(name="logs")
|
||||
@click.argument("run_id")
|
||||
@click.option("--step", "-s", help="Specific step")
|
||||
def runs_logs(run_id: str, step: str):
|
||||
"""View run logs."""
|
||||
params = {}
|
||||
if step:
|
||||
params["step"] = step
|
||||
|
||||
resp = _request("get", f"/v1/runs/{run_id}/logs", params=params)
|
||||
data = check_response(resp)
|
||||
lines = data.get("logs", data.get("lines", []))
|
||||
|
||||
console.print(f"[cyan]Logs for run {run_id}:[/cyan]")
|
||||
console.print("[dim]No logs available[/dim]")
|
||||
for line in lines:
|
||||
if isinstance(line, dict):
|
||||
ts = str(line.get("timestamp", ""))[:19]
|
||||
level = line.get("level", "info")
|
||||
msg = line.get("message", "")
|
||||
style = "red" if level == "error" else "yellow" if level == "warn" else "dim"
|
||||
console.print(f"[dim]{ts}[/dim] [{style}]{level}[/{style}] {msg}")
|
||||
else:
|
||||
console.print(str(line))
|
||||
|
||||
if not lines:
|
||||
console.print("[dim]No logs available[/dim]")
|
||||
|
||||
|
||||
@runs.command(name="retry")
|
||||
@click.argument("run_id")
|
||||
def runs_retry(run_id: str):
|
||||
"""Retry a failed run."""
|
||||
console.print(f"[cyan]Retrying run {run_id}...[/cyan]")
|
||||
console.print("[green]✓[/green] Run restarted")
|
||||
resp = _request("post", f"/v1/runs/{run_id}/retry")
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Run restarted")
|
||||
console.print(f" New Run ID: {data.get('id', data.get('run_id', '-'))}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -346,27 +495,31 @@ def templates():
|
||||
@click.option("--category", "-c", help="Filter by category")
|
||||
def templates_list(category: str):
|
||||
"""List available templates."""
|
||||
params = {}
|
||||
if category:
|
||||
params["category"] = category
|
||||
|
||||
resp = _request("get", "/v1/templates", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("templates", data.get("items", []))
|
||||
|
||||
table = Table(title="Automation Templates", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Category", style="white")
|
||||
table.add_column("Pieces", style="green")
|
||||
table.add_column("Description", style="dim")
|
||||
|
||||
templates_data = [
|
||||
("Slack to Notion", "Productivity", "2", "Save Slack messages to Notion"),
|
||||
("GitHub PR Notify", "Development", "2", "Slack notification on PR"),
|
||||
("Email to CRM", "Sales", "3", "Add email leads to HubSpot"),
|
||||
("Invoice Processor", "Finance", "4", "Process invoices with AI"),
|
||||
("Social Media Scheduler", "Marketing", "5", "Schedule posts across platforms"),
|
||||
("Support Ticket Router", "Support", "3", "AI-powered ticket routing"),
|
||||
]
|
||||
|
||||
for name, cat, pieces, desc in templates_data:
|
||||
if category and category.lower() not in cat.lower():
|
||||
continue
|
||||
table.add_row(name, cat, pieces, desc)
|
||||
for t in items:
|
||||
table.add_row(
|
||||
t.get("name", ""),
|
||||
t.get("category", "-"),
|
||||
str(t.get("piece_count", 0)),
|
||||
t.get("description", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No templates found[/dim]")
|
||||
|
||||
|
||||
@templates.command(name="use")
|
||||
@@ -374,9 +527,16 @@ def templates_list(category: str):
|
||||
@click.option("--name", "-n", help="Flow name")
|
||||
def templates_use(template_name: str, name: str):
|
||||
"""Create flow from template."""
|
||||
flow_name = name or template_name.lower().replace(" ", "-")
|
||||
console.print(f"[cyan]Creating flow from template '{template_name}'...[/cyan]")
|
||||
console.print(f"[green]✓[/green] Flow '{flow_name}' created")
|
||||
body = {"template": template_name}
|
||||
if name:
|
||||
body["name"] = name
|
||||
|
||||
resp = _request("post", "/v1/flows/from-template", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
flow_name = data.get("name", name or template_name.lower().replace(" ", "-"))
|
||||
console.print(f"[green]✓[/green] Flow '{flow_name}' created from template")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print("Configure connections with: hanzo auto connections add <piece>")
|
||||
|
||||
|
||||
@@ -394,14 +554,27 @@ def webhooks():
|
||||
@webhooks.command(name="list")
|
||||
def webhooks_list():
|
||||
"""List webhook endpoints."""
|
||||
resp = _request("get", "/v1/webhooks")
|
||||
data = check_response(resp)
|
||||
items = data.get("webhooks", data.get("items", []))
|
||||
|
||||
table = Table(title="Webhooks", box=box.ROUNDED)
|
||||
table.add_column("Flow", style="cyan")
|
||||
table.add_column("URL", style="white")
|
||||
table.add_column("Method", style="green")
|
||||
table.add_column("Calls", style="dim")
|
||||
|
||||
for w in items:
|
||||
table.add_row(
|
||||
w.get("flow", ""),
|
||||
w.get("url", "-"),
|
||||
w.get("method", "POST"),
|
||||
str(w.get("call_count", 0)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No webhooks found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No webhooks found[/dim]")
|
||||
|
||||
|
||||
@webhooks.command(name="test")
|
||||
@@ -409,8 +582,18 @@ def webhooks_list():
|
||||
@click.option("--data", "-d", help="JSON payload")
|
||||
def webhooks_test(flow_name: str, data: str):
|
||||
"""Test webhook endpoint."""
|
||||
body = {}
|
||||
if data:
|
||||
body = json.loads(data)
|
||||
|
||||
console.print(f"[cyan]Testing webhook for '{flow_name}'...[/cyan]")
|
||||
console.print("[green]✓[/green] Webhook triggered successfully")
|
||||
resp = _request("post", f"/v1/webhooks/{flow_name}/test", json=body)
|
||||
result = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Webhook triggered successfully")
|
||||
console.print(f" Status: {result.get('status_code', '-')}")
|
||||
if result.get("run_id"):
|
||||
console.print(f" Run ID: {result['run_id']}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -435,12 +618,19 @@ def ai_generate(prompt: str, name: str):
|
||||
hanzo auto ai generate -p "When I get an email from a customer, summarize it and post to Slack"
|
||||
hanzo auto ai generate -p "Every morning, send me a summary of GitHub issues"
|
||||
"""
|
||||
console.print(f"[cyan]Generating flow from prompt...[/cyan]")
|
||||
console.print(f" Prompt: {prompt}")
|
||||
console.print()
|
||||
console.print("[green]✓[/green] Flow generated")
|
||||
console.print(" Steps: 3")
|
||||
console.print(" Pieces: gmail, openai, slack")
|
||||
body = {"prompt": prompt}
|
||||
if name:
|
||||
body["name"] = name
|
||||
|
||||
console.print("[cyan]Generating flow from prompt...[/cyan]")
|
||||
resp = _request("post", "/v1/ai/generate", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Flow generated")
|
||||
console.print(f" Name: {data.get('name', '-')}")
|
||||
console.print(f" Steps: {data.get('step_count', 0)}")
|
||||
if data.get("pieces"):
|
||||
console.print(f" Pieces: {', '.join(data['pieces'])}")
|
||||
|
||||
|
||||
@ai.command(name="suggest")
|
||||
@@ -448,11 +638,17 @@ def ai_generate(prompt: str, name: str):
|
||||
def ai_suggest(flow_name: str):
|
||||
"""Get AI suggestions to improve a flow."""
|
||||
console.print(f"[cyan]Analyzing flow '{flow_name}'...[/cyan]")
|
||||
console.print()
|
||||
console.print("[cyan]Suggestions:[/cyan]")
|
||||
console.print(" 1. Add error handling for API failures")
|
||||
console.print(" 2. Consider adding a retry step")
|
||||
console.print(" 3. Add a filter to reduce unnecessary runs")
|
||||
resp = _request("post", f"/v1/ai/suggest", json={"flow": flow_name})
|
||||
data = check_response(resp)
|
||||
|
||||
suggestions = data.get("suggestions", [])
|
||||
if suggestions:
|
||||
console.print()
|
||||
console.print("[cyan]Suggestions:[/cyan]")
|
||||
for i, s in enumerate(suggestions, 1):
|
||||
console.print(f" {i}. {s}")
|
||||
else:
|
||||
console.print("[dim]No suggestions at this time[/dim]")
|
||||
|
||||
|
||||
@ai.command(name="explain")
|
||||
@@ -460,8 +656,9 @@ def ai_suggest(flow_name: str):
|
||||
def ai_explain(flow_name: str):
|
||||
"""Get AI explanation of what a flow does."""
|
||||
console.print(f"[cyan]Explaining flow '{flow_name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/ai/explain", json={"flow": flow_name})
|
||||
data = check_response(resp)
|
||||
|
||||
explanation = data.get("explanation", "No explanation available")
|
||||
console.print()
|
||||
console.print("[dim]This flow does the following:[/dim]")
|
||||
console.print(" 1. Triggers when...")
|
||||
console.print(" 2. Then it...")
|
||||
console.print(" 3. Finally it...")
|
||||
console.print(explanation)
|
||||
|
||||
@@ -94,6 +94,43 @@ def api_request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return getattr(client, method)(f"{HANZO_API_URL}{path}", headers=headers, **kwargs)
|
||||
|
||||
|
||||
def service_request(base_url: str, method: str, path: str, **kwargs) -> httpx.Response:
|
||||
"""Make authenticated request to a Hanzo service.
|
||||
|
||||
Shared utility for all service CLIs (vector, kv, search, etc).
|
||||
Each service file defines its own SERVICE_URL and calls this.
|
||||
"""
|
||||
api_key = get_api_key()
|
||||
if not api_key:
|
||||
raise click.ClickException("Not authenticated. Run 'hanzo login' first.")
|
||||
|
||||
headers = kwargs.pop("headers", {})
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=60) as client:
|
||||
return getattr(client, method)(f"{base_url}{path}", headers=headers, **kwargs)
|
||||
except httpx.ConnectError:
|
||||
raise click.ClickException(f"Could not connect to {base_url}")
|
||||
|
||||
|
||||
def check_response(resp: httpx.Response) -> dict:
|
||||
"""Check API response and return JSON data or raise error."""
|
||||
if resp.status_code >= 400:
|
||||
try:
|
||||
error = resp.json()
|
||||
msg = error.get("message", error.get("error", resp.text))
|
||||
except Exception:
|
||||
msg = resp.text
|
||||
raise click.ClickException(f"API error ({resp.status_code}): {msg}")
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main base group
|
||||
# ============================================================================
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Support, CRM, and ERP.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
SERVICE_URL = os.getenv("HANZO_CX_URL", "https://cx.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(SERVICE_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="cx")
|
||||
@@ -49,8 +60,17 @@ def inbox():
|
||||
@inbox.command(name="list")
|
||||
@click.option("--status", type=click.Choice(["open", "pending", "resolved", "all"]), default="open")
|
||||
@click.option("--channel", type=click.Choice(["email", "chat", "social", "all"]), default="all")
|
||||
def inbox_list(status: str, channel: str):
|
||||
@click.option("--limit", "-n", default=50, help="Max results")
|
||||
def inbox_list(status: str, channel: str, limit: int):
|
||||
"""List inbox conversations."""
|
||||
params: dict = {"limit": limit}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
if channel != "all":
|
||||
params["channel"] = channel
|
||||
resp = _request("get", "/v1/inbox/conversations", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Support Inbox", box=box.ROUNDED)
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Subject", style="white")
|
||||
@@ -59,6 +79,18 @@ def inbox_list(status: str, channel: str):
|
||||
table.add_column("Assignee", style="dim")
|
||||
table.add_column("Updated", style="dim")
|
||||
|
||||
for c in data.get("conversations", []):
|
||||
st = c.get("status", "open")
|
||||
st_style = {"open": "yellow", "pending": "cyan", "resolved": "green"}.get(st, "white")
|
||||
table.add_row(
|
||||
c.get("id", "")[:12],
|
||||
c.get("subject", ""),
|
||||
c.get("channel", ""),
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
c.get("assignee", "unassigned"),
|
||||
c.get("updated_at", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -66,18 +98,29 @@ def inbox_list(status: str, channel: str):
|
||||
@click.argument("conversation_id")
|
||||
def inbox_show(conversation_id: str):
|
||||
"""Show conversation details."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]ID:[/cyan] {conversation_id}\n"
|
||||
f"[cyan]Subject:[/cyan] Need help with billing\n"
|
||||
f"[cyan]Customer:[/cyan] [email protected]\n"
|
||||
f"[cyan]Status:[/cyan] Open\n"
|
||||
f"[cyan]Channel:[/cyan] Email\n"
|
||||
f"[cyan]Messages:[/cyan] 5",
|
||||
title="Conversation",
|
||||
border_style="cyan",
|
||||
)
|
||||
resp = _request("get", f"/v1/inbox/conversations/{conversation_id}")
|
||||
data = check_response(resp)
|
||||
|
||||
info = (
|
||||
f"[cyan]ID:[/cyan] {data.get('id', conversation_id)}\n"
|
||||
f"[cyan]Subject:[/cyan] {data.get('subject', 'N/A')}\n"
|
||||
f"[cyan]Customer:[/cyan] {data.get('customer_email', 'N/A')}\n"
|
||||
f"[cyan]Status:[/cyan] {data.get('status', 'N/A')}\n"
|
||||
f"[cyan]Channel:[/cyan] {data.get('channel', 'N/A')}\n"
|
||||
f"[cyan]Messages:[/cyan] {data.get('message_count', 0)}\n"
|
||||
f"[cyan]Assignee:[/cyan] {data.get('assignee', 'unassigned')}"
|
||||
)
|
||||
console.print(Panel(info, title="Conversation", border_style="cyan"))
|
||||
|
||||
messages = data.get("messages", [])
|
||||
if messages:
|
||||
console.print()
|
||||
for msg in messages:
|
||||
sender = msg.get("sender", "unknown")
|
||||
style = "cyan" if msg.get("is_agent") else "white"
|
||||
console.print(f"[{style}]{sender}[/{style}] [dim]{msg.get('created_at', '')}[/dim]")
|
||||
console.print(f" {msg.get('body', '')}")
|
||||
console.print()
|
||||
|
||||
|
||||
@inbox.command(name="assign")
|
||||
@@ -85,6 +128,8 @@ def inbox_show(conversation_id: str):
|
||||
@click.option("--agent", "-a", required=True, help="Agent email or ID")
|
||||
def inbox_assign(conversation_id: str, agent: str):
|
||||
"""Assign conversation to agent."""
|
||||
resp = _request("post", f"/v1/inbox/conversations/{conversation_id}/assign", json={"agent": agent})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Conversation assigned to {agent}")
|
||||
|
||||
|
||||
@@ -93,6 +138,8 @@ def inbox_assign(conversation_id: str, agent: str):
|
||||
@click.option("--message", "-m", prompt=True, help="Reply message")
|
||||
def inbox_reply(conversation_id: str, message: str):
|
||||
"""Reply to a conversation."""
|
||||
resp = _request("post", f"/v1/inbox/conversations/{conversation_id}/reply", json={"body": message})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Reply sent")
|
||||
|
||||
|
||||
@@ -100,6 +147,8 @@ def inbox_reply(conversation_id: str, message: str):
|
||||
@click.argument("conversation_id")
|
||||
def inbox_resolve(conversation_id: str):
|
||||
"""Mark conversation as resolved."""
|
||||
resp = _request("post", f"/v1/inbox/conversations/{conversation_id}/resolve")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Conversation resolved")
|
||||
|
||||
|
||||
@@ -107,6 +156,8 @@ def inbox_resolve(conversation_id: str):
|
||||
@click.argument("conversation_id")
|
||||
def inbox_reopen(conversation_id: str):
|
||||
"""Reopen a resolved conversation."""
|
||||
resp = _request("post", f"/v1/inbox/conversations/{conversation_id}/reopen")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Conversation reopened")
|
||||
|
||||
|
||||
@@ -126,6 +177,12 @@ def contacts():
|
||||
@click.option("--limit", "-n", default=50, help="Max results")
|
||||
def contacts_list(search: str, limit: int):
|
||||
"""List contacts."""
|
||||
params: dict = {"limit": limit}
|
||||
if search:
|
||||
params["search"] = search
|
||||
resp = _request("get", "/v1/contacts", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Contacts", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Email", style="white")
|
||||
@@ -133,6 +190,15 @@ def contacts_list(search: str, limit: int):
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for c in data.get("contacts", []):
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
c.get("email", ""),
|
||||
c.get("company", ""),
|
||||
c.get("status", "active"),
|
||||
c.get("created_at", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -140,17 +206,17 @@ def contacts_list(search: str, limit: int):
|
||||
@click.argument("contact_id")
|
||||
def contacts_show(contact_id: str):
|
||||
"""Show contact details."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] John Doe\n"
|
||||
f"[cyan]Email:[/cyan] [email protected]\n"
|
||||
f"[cyan]Company:[/cyan] Acme Inc\n"
|
||||
f"[cyan]Phone:[/cyan] +1 555-1234\n"
|
||||
f"[cyan]Deals:[/cyan] 2 ($50,000)",
|
||||
title="Contact Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
resp = _request("get", f"/v1/contacts/{contact_id}")
|
||||
data = check_response(resp)
|
||||
|
||||
info = (
|
||||
f"[cyan]Name:[/cyan] {data.get('name', 'N/A')}\n"
|
||||
f"[cyan]Email:[/cyan] {data.get('email', 'N/A')}\n"
|
||||
f"[cyan]Company:[/cyan] {data.get('company', 'N/A')}\n"
|
||||
f"[cyan]Phone:[/cyan] {data.get('phone', 'N/A')}\n"
|
||||
f"[cyan]Deals:[/cyan] {data.get('deal_count', 0)} (${data.get('deal_value', 0):,.0f})"
|
||||
)
|
||||
console.print(Panel(info, title="Contact Details", border_style="cyan"))
|
||||
|
||||
|
||||
@contacts.command(name="create")
|
||||
@@ -160,13 +226,46 @@ def contacts_show(contact_id: str):
|
||||
@click.option("--phone", "-p", help="Phone")
|
||||
def contacts_create(name: str, email: str, company: str, phone: str):
|
||||
"""Create a contact."""
|
||||
payload: dict = {"name": name, "email": email}
|
||||
if company:
|
||||
payload["company"] = company
|
||||
if phone:
|
||||
payload["phone"] = phone
|
||||
resp = _request("post", "/v1/contacts", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Contact '{name}' created")
|
||||
|
||||
|
||||
@contacts.command(name="update")
|
||||
@click.argument("contact_id")
|
||||
@click.option("--name", "-n", help="Contact name")
|
||||
@click.option("--email", "-e", help="Email")
|
||||
@click.option("--company", "-c", help="Company")
|
||||
@click.option("--phone", "-p", help="Phone")
|
||||
def contacts_update(contact_id: str, name: str, email: str, company: str, phone: str):
|
||||
"""Update a contact."""
|
||||
payload: dict = {}
|
||||
if name:
|
||||
payload["name"] = name
|
||||
if email:
|
||||
payload["email"] = email
|
||||
if company:
|
||||
payload["company"] = company
|
||||
if phone:
|
||||
payload["phone"] = phone
|
||||
if not payload:
|
||||
raise click.ClickException("Provide at least one field to update")
|
||||
resp = _request("patch", f"/v1/contacts/{contact_id}", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Contact updated")
|
||||
|
||||
|
||||
@contacts.command(name="delete")
|
||||
@click.argument("contact_id")
|
||||
def contacts_delete(contact_id: str):
|
||||
"""Delete a contact."""
|
||||
resp = _request("delete", f"/v1/contacts/{contact_id}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Contact deleted")
|
||||
|
||||
|
||||
@@ -184,8 +283,17 @@ def deals():
|
||||
@deals.command(name="list")
|
||||
@click.option("--pipeline", "-p", help="Filter by pipeline")
|
||||
@click.option("--stage", "-s", help="Filter by stage")
|
||||
def deals_list(pipeline: str, stage: str):
|
||||
@click.option("--limit", "-n", default=50, help="Max results")
|
||||
def deals_list(pipeline: str, stage: str, limit: int):
|
||||
"""List deals."""
|
||||
params: dict = {"limit": limit}
|
||||
if pipeline:
|
||||
params["pipeline"] = pipeline
|
||||
if stage:
|
||||
params["stage"] = stage
|
||||
resp = _request("get", "/v1/deals", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Deals", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Value", style="green")
|
||||
@@ -193,6 +301,15 @@ def deals_list(pipeline: str, stage: str):
|
||||
table.add_column("Contact", style="dim")
|
||||
table.add_column("Close Date", style="dim")
|
||||
|
||||
for d in data.get("deals", []):
|
||||
table.add_row(
|
||||
d.get("name", ""),
|
||||
f"${d.get('value', 0):,.0f}",
|
||||
d.get("stage", ""),
|
||||
d.get("contact_name", ""),
|
||||
d.get("close_date", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -200,17 +317,18 @@ def deals_list(pipeline: str, stage: str):
|
||||
@click.argument("deal_id")
|
||||
def deals_show(deal_id: str):
|
||||
"""Show deal details."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Deal:[/cyan] Enterprise License\n"
|
||||
f"[cyan]Value:[/cyan] $50,000\n"
|
||||
f"[cyan]Stage:[/cyan] Negotiation\n"
|
||||
f"[cyan]Contact:[/cyan] John Doe\n"
|
||||
f"[cyan]Close Date:[/cyan] 2024-02-15",
|
||||
title="Deal Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
resp = _request("get", f"/v1/deals/{deal_id}")
|
||||
data = check_response(resp)
|
||||
|
||||
info = (
|
||||
f"[cyan]Deal:[/cyan] {data.get('name', 'N/A')}\n"
|
||||
f"[cyan]Value:[/cyan] ${data.get('value', 0):,.0f}\n"
|
||||
f"[cyan]Stage:[/cyan] {data.get('stage', 'N/A')}\n"
|
||||
f"[cyan]Pipeline:[/cyan] {data.get('pipeline', 'default')}\n"
|
||||
f"[cyan]Contact:[/cyan] {data.get('contact_name', 'N/A')}\n"
|
||||
f"[cyan]Close Date:[/cyan] {data.get('close_date', 'N/A')}"
|
||||
)
|
||||
console.print(Panel(info, title="Deal Details", border_style="cyan"))
|
||||
|
||||
|
||||
@deals.command(name="create")
|
||||
@@ -218,8 +336,14 @@ def deals_show(deal_id: str):
|
||||
@click.option("--value", "-v", type=float, prompt=True, help="Deal value")
|
||||
@click.option("--contact", "-c", required=True, help="Contact ID")
|
||||
@click.option("--pipeline", "-p", default="default", help="Pipeline")
|
||||
def deals_create(name: str, value: float, contact: str, pipeline: str):
|
||||
@click.option("--stage", "-s", help="Initial stage")
|
||||
def deals_create(name: str, value: float, contact: str, pipeline: str, stage: str):
|
||||
"""Create a deal."""
|
||||
payload: dict = {"name": name, "value": value, "contact_id": contact, "pipeline": pipeline}
|
||||
if stage:
|
||||
payload["stage"] = stage
|
||||
resp = _request("post", "/v1/deals", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Deal '{name}' created (${value:,.0f})")
|
||||
|
||||
|
||||
@@ -228,9 +352,20 @@ def deals_create(name: str, value: float, contact: str, pipeline: str):
|
||||
@click.option("--stage", "-s", required=True, help="Target stage")
|
||||
def deals_move(deal_id: str, stage: str):
|
||||
"""Move deal to a stage."""
|
||||
resp = _request("patch", f"/v1/deals/{deal_id}", json={"stage": stage})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Deal moved to '{stage}'")
|
||||
|
||||
|
||||
@deals.command(name="delete")
|
||||
@click.argument("deal_id")
|
||||
def deals_delete(deal_id: str):
|
||||
"""Delete a deal."""
|
||||
resp = _request("delete", f"/v1/deals/{deal_id}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Deal deleted")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Pipelines (CRM)
|
||||
# ============================================================================
|
||||
@@ -245,13 +380,23 @@ def pipelines():
|
||||
@pipelines.command(name="list")
|
||||
def pipelines_list():
|
||||
"""List pipelines."""
|
||||
resp = _request("get", "/v1/pipelines")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Pipelines", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Stages", style="white")
|
||||
table.add_column("Deals", style="dim")
|
||||
table.add_column("Value", style="green")
|
||||
|
||||
table.add_row("Default", "Lead → Qualified → Proposal → Negotiation → Won", "12", "$234,500")
|
||||
for p in data.get("pipelines", []):
|
||||
stages = " → ".join(p.get("stages", []))
|
||||
table.add_row(
|
||||
p.get("name", ""),
|
||||
stages,
|
||||
str(p.get("deal_count", 0)),
|
||||
f"${p.get('total_value', 0):,.0f}",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
@@ -261,9 +406,21 @@ def pipelines_list():
|
||||
@click.option("--stages", "-s", required=True, help="Comma-separated stages")
|
||||
def pipelines_create(name: str, stages: str):
|
||||
"""Create a pipeline."""
|
||||
stage_list = [s.strip() for s in stages.split(",")]
|
||||
resp = _request("post", "/v1/pipelines", json={"name": name, "stages": stage_list})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Pipeline '{name}' created")
|
||||
|
||||
|
||||
@pipelines.command(name="delete")
|
||||
@click.argument("name")
|
||||
def pipelines_delete(name: str):
|
||||
"""Delete a pipeline."""
|
||||
resp = _request("delete", f"/v1/pipelines/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Pipeline '{name}' deleted")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Invoices (ERP)
|
||||
# ============================================================================
|
||||
@@ -277,8 +434,15 @@ def invoices():
|
||||
|
||||
@invoices.command(name="list")
|
||||
@click.option("--status", type=click.Choice(["draft", "sent", "paid", "overdue", "all"]), default="all")
|
||||
def invoices_list(status: str):
|
||||
@click.option("--limit", "-n", default=50, help="Max results")
|
||||
def invoices_list(status: str, limit: int):
|
||||
"""List invoices."""
|
||||
params: dict = {"limit": limit}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
resp = _request("get", "/v1/invoices", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Invoices", box=box.ROUNDED)
|
||||
table.add_column("Number", style="cyan")
|
||||
table.add_column("Customer", style="white")
|
||||
@@ -286,32 +450,68 @@ def invoices_list(status: str):
|
||||
table.add_column("Status", style="yellow")
|
||||
table.add_column("Due Date", style="dim")
|
||||
|
||||
for inv in data.get("invoices", []):
|
||||
st = inv.get("status", "draft")
|
||||
st_style = {"paid": "green", "overdue": "red", "sent": "cyan"}.get(st, "yellow")
|
||||
table.add_row(
|
||||
inv.get("number", ""),
|
||||
inv.get("customer_name", ""),
|
||||
f"${inv.get('amount', 0):,.2f}",
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
inv.get("due_date", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@invoices.command(name="create")
|
||||
@click.option("--customer", "-c", required=True, help="Customer ID")
|
||||
@click.option("--amount", "-a", type=float, required=True, help="Amount")
|
||||
@click.option("--due", "-d", help="Due date")
|
||||
def invoices_create(customer: str, amount: float, due: str):
|
||||
@click.option("--due", "-d", help="Due date (YYYY-MM-DD)")
|
||||
@click.option("--items", help="Line items JSON")
|
||||
def invoices_create(customer: str, amount: float, due: str, items: str):
|
||||
"""Create an invoice."""
|
||||
console.print(f"[green]✓[/green] Invoice created for ${amount:,.2f}")
|
||||
payload: dict = {"customer_id": customer, "amount": amount}
|
||||
if due:
|
||||
payload["due_date"] = due
|
||||
if items:
|
||||
payload["items"] = json.loads(items)
|
||||
resp = _request("post", "/v1/invoices", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Invoice {data.get('number', '')} created for ${amount:,.2f}")
|
||||
|
||||
|
||||
@invoices.command(name="send")
|
||||
@click.argument("invoice_number")
|
||||
def invoices_send(invoice_number: str):
|
||||
"""Send an invoice."""
|
||||
resp = _request("post", f"/v1/invoices/{invoice_number}/send")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Invoice {invoice_number} sent")
|
||||
|
||||
|
||||
@invoices.command(name="mark-paid")
|
||||
@click.argument("invoice_number")
|
||||
def invoices_mark_paid(invoice_number: str):
|
||||
@click.option("--payment-method", help="Payment method used")
|
||||
def invoices_mark_paid(invoice_number: str, payment_method: str):
|
||||
"""Mark invoice as paid."""
|
||||
payload: dict = {}
|
||||
if payment_method:
|
||||
payload["payment_method"] = payment_method
|
||||
resp = _request("post", f"/v1/invoices/{invoice_number}/pay", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Invoice {invoice_number} marked as paid")
|
||||
|
||||
|
||||
@invoices.command(name="delete")
|
||||
@click.argument("invoice_number")
|
||||
def invoices_delete(invoice_number: str):
|
||||
"""Delete an invoice."""
|
||||
resp = _request("delete", f"/v1/invoices/{invoice_number}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Invoice {invoice_number} deleted")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Orders (ERP)
|
||||
# ============================================================================
|
||||
@@ -325,8 +525,15 @@ def orders():
|
||||
|
||||
@orders.command(name="list")
|
||||
@click.option("--status", type=click.Choice(["pending", "processing", "shipped", "delivered", "all"]), default="all")
|
||||
def orders_list(status: str):
|
||||
@click.option("--limit", "-n", default=50, help="Max results")
|
||||
def orders_list(status: str, limit: int):
|
||||
"""List orders."""
|
||||
params: dict = {"limit": limit}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
resp = _request("get", "/v1/orders", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Orders", box=box.ROUNDED)
|
||||
table.add_column("Order #", style="cyan")
|
||||
table.add_column("Customer", style="white")
|
||||
@@ -334,6 +541,17 @@ def orders_list(status: str):
|
||||
table.add_column("Status", style="yellow")
|
||||
table.add_column("Date", style="dim")
|
||||
|
||||
for o in data.get("orders", []):
|
||||
st = o.get("status", "pending")
|
||||
st_style = {"delivered": "green", "shipped": "cyan", "processing": "yellow"}.get(st, "white")
|
||||
table.add_row(
|
||||
o.get("order_number", ""),
|
||||
o.get("customer_name", ""),
|
||||
f"${o.get('total', 0):,.2f}",
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
o.get("created_at", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -341,22 +559,46 @@ def orders_list(status: str):
|
||||
@click.argument("order_id")
|
||||
def orders_show(order_id: str):
|
||||
"""Show order details."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Order #:[/cyan] {order_id}\n"
|
||||
f"[cyan]Customer:[/cyan] John Doe\n"
|
||||
f"[cyan]Total:[/cyan] $299.00\n"
|
||||
f"[cyan]Status:[/cyan] Processing\n"
|
||||
f"[cyan]Items:[/cyan] 3",
|
||||
title="Order Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
resp = _request("get", f"/v1/orders/{order_id}")
|
||||
data = check_response(resp)
|
||||
|
||||
info = (
|
||||
f"[cyan]Order #:[/cyan] {data.get('order_number', order_id)}\n"
|
||||
f"[cyan]Customer:[/cyan] {data.get('customer_name', 'N/A')}\n"
|
||||
f"[cyan]Total:[/cyan] ${data.get('total', 0):,.2f}\n"
|
||||
f"[cyan]Status:[/cyan] {data.get('status', 'N/A')}\n"
|
||||
f"[cyan]Items:[/cyan] {data.get('item_count', 0)}"
|
||||
)
|
||||
console.print(Panel(info, title="Order Details", border_style="cyan"))
|
||||
|
||||
items = data.get("items", [])
|
||||
if items:
|
||||
table = Table(title="Order Items", box=box.ROUNDED)
|
||||
table.add_column("Product", style="white")
|
||||
table.add_column("Qty", style="dim")
|
||||
table.add_column("Price", style="green")
|
||||
table.add_column("Subtotal", style="green")
|
||||
|
||||
for item in items:
|
||||
table.add_row(
|
||||
item.get("product_name", ""),
|
||||
str(item.get("quantity", 0)),
|
||||
f"${item.get('unit_price', 0):,.2f}",
|
||||
f"${item.get('subtotal', 0):,.2f}",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@orders.command(name="update-status")
|
||||
@click.argument("order_id")
|
||||
@click.option("--status", "-s", type=click.Choice(["processing", "shipped", "delivered"]), required=True)
|
||||
def orders_update_status(order_id: str, status: str):
|
||||
@click.option("--tracking", "-t", help="Tracking number")
|
||||
def orders_update_status(order_id: str, status: str, tracking: str):
|
||||
"""Update order status."""
|
||||
payload: dict = {"status": status}
|
||||
if tracking:
|
||||
payload["tracking_number"] = tracking
|
||||
resp = _request("patch", f"/v1/orders/{order_id}", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Order {order_id} status updated to '{status}'")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
MongoDB-compatible document database with global distribution.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
DOC_URL = os.getenv("HANZO_DOC_URL", "https://doc.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(DOC_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="doc")
|
||||
@@ -54,16 +65,28 @@ def doc_group():
|
||||
@click.option("--tier", "-t", type=click.Choice(["free", "standard", "dedicated"]), default="standard")
|
||||
def doc_create(name: str, region: tuple, tier: str):
|
||||
"""Create a document database."""
|
||||
payload = {"name": name, "tier": tier}
|
||||
if region:
|
||||
payload["regions"] = list(region)
|
||||
|
||||
resp = _request("post", "/v1/databases", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Database '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Tier: {tier}")
|
||||
if region:
|
||||
console.print(f" Regions: {', '.join(region)}")
|
||||
console.print(f" Connection: mongodb://doc.hanzo.ai/{name}")
|
||||
console.print(f" Connection: {data.get('connection_string', f'mongodb://doc.hanzo.ai/{name}')}")
|
||||
|
||||
|
||||
@doc_group.command(name="list")
|
||||
def doc_list():
|
||||
"""List document databases."""
|
||||
resp = _request("get", "/v1/databases")
|
||||
data = check_response(resp)
|
||||
dbs = data.get("databases", data.get("items", []))
|
||||
|
||||
table = Table(title="Document Databases", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Tier", style="white")
|
||||
@@ -71,24 +94,42 @@ def doc_list():
|
||||
table.add_column("Size", style="yellow")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for db in dbs:
|
||||
status = db.get("status", "unknown")
|
||||
style = "green" if status == "running" else "yellow"
|
||||
table.add_row(
|
||||
db.get("name", ""),
|
||||
db.get("tier", "-"),
|
||||
str(db.get("collection_count", 0)),
|
||||
db.get("size", "0 B"),
|
||||
f"[{style}]● {status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No databases found. Create one with 'hanzo doc create'[/dim]")
|
||||
if not dbs:
|
||||
console.print("[dim]No databases found. Create one with 'hanzo doc create'[/dim]")
|
||||
|
||||
|
||||
@doc_group.command(name="describe")
|
||||
@click.argument("name")
|
||||
def doc_describe(name: str):
|
||||
"""Show database details."""
|
||||
resp = _request("get", f"/v1/databases/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "unknown")
|
||||
style = "green" if status == "running" else "yellow"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Tier:[/cyan] Standard\n"
|
||||
f"[cyan]Status:[/cyan] [green]● Running[/green]\n"
|
||||
f"[cyan]Collections:[/cyan] 5\n"
|
||||
f"[cyan]Documents:[/cyan] 12,345\n"
|
||||
f"[cyan]Size:[/cyan] 256 MB\n"
|
||||
f"[cyan]Regions:[/cyan] us-east-1\n"
|
||||
f"[cyan]Connection:[/cyan] mongodb://doc.hanzo.ai/{name}",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Tier:[/cyan] {data.get('tier', '-')}\n"
|
||||
f"[cyan]Status:[/cyan] [{style}]● {status}[/{style}]\n"
|
||||
f"[cyan]Collections:[/cyan] {data.get('collection_count', 0)}\n"
|
||||
f"[cyan]Documents:[/cyan] {data.get('document_count', 0):,}\n"
|
||||
f"[cyan]Size:[/cyan] {data.get('size', '0 B')}\n"
|
||||
f"[cyan]Regions:[/cyan] {', '.join(data.get('regions', ['-']))}\n"
|
||||
f"[cyan]Connection:[/cyan] {data.get('connection_string', f'mongodb://doc.hanzo.ai/{name}')}",
|
||||
title="Database Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -105,6 +146,9 @@ def doc_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete database '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/databases/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Database '{name}' deleted")
|
||||
|
||||
|
||||
@@ -112,8 +156,11 @@ def doc_delete(name: str, force: bool):
|
||||
@click.argument("name")
|
||||
def doc_connect(name: str):
|
||||
"""Get connection string."""
|
||||
resp = _request("get", f"/v1/databases/{name}")
|
||||
data = check_response(resp)
|
||||
conn = data.get("connection_string", f"mongodb://doc.hanzo.ai/{name}?authSource=admin")
|
||||
console.print(f"[cyan]Connection string for '{name}':[/cyan]")
|
||||
console.print(f"mongodb://doc.hanzo.ai/{name}?authSource=admin")
|
||||
console.print(conn)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -131,14 +178,27 @@ def collections():
|
||||
@click.option("--db", "-d", default="default", help="Database name")
|
||||
def collections_list(db: str):
|
||||
"""List collections in a database."""
|
||||
resp = _request("get", f"/v1/databases/{db}/collections")
|
||||
data = check_response(resp)
|
||||
items = data.get("collections", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Collections in '{db}'", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Documents", style="green")
|
||||
table.add_column("Size", style="yellow")
|
||||
table.add_column("Indexes", style="dim")
|
||||
|
||||
for c in items:
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
str(c.get("document_count", 0)),
|
||||
c.get("size", "0 B"),
|
||||
str(c.get("index_count", 0)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No collections found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No collections found[/dim]")
|
||||
|
||||
|
||||
@collections.command(name="create")
|
||||
@@ -149,6 +209,17 @@ def collections_list(db: str):
|
||||
@click.option("--validator", "-v", help="JSON schema validator")
|
||||
def collections_create(name: str, db: str, capped: bool, size: str, validator: str):
|
||||
"""Create a collection."""
|
||||
payload = {"name": name}
|
||||
if capped:
|
||||
payload["capped"] = True
|
||||
if size:
|
||||
payload["max_size"] = size
|
||||
if validator:
|
||||
payload["validator"] = json.loads(validator)
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections", json=payload)
|
||||
check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Collection '{name}' created in '{db}'")
|
||||
if capped:
|
||||
console.print(f" Capped: Yes (max {size})")
|
||||
@@ -165,6 +236,9 @@ def collections_drop(name: str, db: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Drop collection '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/databases/{db}/collections/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Collection '{name}' dropped")
|
||||
|
||||
|
||||
@@ -173,14 +247,17 @@ def collections_drop(name: str, db: str, force: bool):
|
||||
@click.option("--db", "-d", default="default")
|
||||
def collections_stats(name: str, db: str):
|
||||
"""Show collection statistics."""
|
||||
resp = _request("get", f"/v1/databases/{db}/collections/{name}/stats")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Collection:[/cyan] {name}\n"
|
||||
f"[cyan]Documents:[/cyan] 1,234\n"
|
||||
f"[cyan]Size:[/cyan] 12.5 MB\n"
|
||||
f"[cyan]Avg doc size:[/cyan] 10.1 KB\n"
|
||||
f"[cyan]Indexes:[/cyan] 3\n"
|
||||
f"[cyan]Index size:[/cyan] 1.2 MB",
|
||||
f"[cyan]Collection:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Documents:[/cyan] {data.get('document_count', 0):,}\n"
|
||||
f"[cyan]Size:[/cyan] {data.get('size', '0 B')}\n"
|
||||
f"[cyan]Avg doc size:[/cyan] {data.get('avg_doc_size', '0 B')}\n"
|
||||
f"[cyan]Indexes:[/cyan] {data.get('index_count', 0)}\n"
|
||||
f"[cyan]Index size:[/cyan] {data.get('index_size', '0 B')}",
|
||||
title="Collection Statistics",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -209,8 +286,27 @@ def doc_find(collection: str, db: str, query: str, projection: str, sort: str, l
|
||||
hanzo doc find users -q '{"status": "active"}'
|
||||
hanzo doc find users -q '{"age": {"$gt": 21}}' -s '{"name": 1}'
|
||||
"""
|
||||
payload = {"filter": json.loads(query), "limit": limit}
|
||||
if projection:
|
||||
payload["projection"] = json.loads(projection)
|
||||
if sort:
|
||||
payload["sort"] = json.loads(sort)
|
||||
if skip:
|
||||
payload["skip"] = skip
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/find", json=payload)
|
||||
data = check_response(resp)
|
||||
docs = data.get("documents", data.get("items", []))
|
||||
|
||||
console.print(f"[cyan]Results from {db}.{collection}:[/cyan]")
|
||||
console.print("[dim]No documents found[/dim]")
|
||||
for doc in docs:
|
||||
console.print(json.dumps(doc, indent=2, default=str))
|
||||
|
||||
total = data.get("total", len(docs))
|
||||
if not docs:
|
||||
console.print("[dim]No documents found[/dim]")
|
||||
else:
|
||||
console.print(f"\n[dim]{total} total, showing {len(docs)}[/dim]")
|
||||
|
||||
|
||||
@doc_group.command(name="insert")
|
||||
@@ -220,9 +316,20 @@ def doc_find(collection: str, db: str, query: str, projection: str, sort: str, l
|
||||
@click.option("--file", "-f", type=click.Path(exists=True), help="Document file")
|
||||
def doc_insert(collection: str, db: str, doc: str, file: str):
|
||||
"""Insert a document."""
|
||||
doc_id = "64f1a2b3c4d5e6f7a8b9c0d1"
|
||||
if file:
|
||||
from pathlib import Path
|
||||
|
||||
document = json.loads(Path(file).read_text())
|
||||
elif doc:
|
||||
document = json.loads(doc)
|
||||
else:
|
||||
raise click.ClickException("Provide --doc or --file")
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/insert", json={"document": document})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Inserted document into '{collection}'")
|
||||
console.print(f" _id: {doc_id}")
|
||||
console.print(f" _id: {data.get('inserted_id', data.get('_id', '-'))}")
|
||||
|
||||
|
||||
@doc_group.command(name="update")
|
||||
@@ -240,8 +347,29 @@ def doc_update(collection: str, db: str, filter: str, set_fields: tuple, unset_f
|
||||
hanzo doc update users -q '{"status": "pending"}' --set status=active
|
||||
hanzo doc update users -q '{"_id": "..."}' --set name=John --set age=30
|
||||
"""
|
||||
update_doc = {}
|
||||
if set_fields:
|
||||
set_dict = {}
|
||||
for field in set_fields:
|
||||
k, v = field.split("=", 1)
|
||||
set_dict[k] = v
|
||||
update_doc["$set"] = set_dict
|
||||
if unset_fields:
|
||||
update_doc["$unset"] = {f: "" for f in unset_fields}
|
||||
|
||||
payload = {
|
||||
"filter": json.loads(filter),
|
||||
"update": update_doc,
|
||||
"upsert": upsert,
|
||||
}
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/update", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Updated documents in '{collection}'")
|
||||
console.print(" Matched: 1, Modified: 1")
|
||||
console.print(f" Matched: {data.get('matched_count', 0)}, Modified: {data.get('modified_count', 0)}")
|
||||
if upsert and data.get("upserted_id"):
|
||||
console.print(f" Upserted: {data['upserted_id']}")
|
||||
|
||||
|
||||
@doc_group.command(name="delete-docs")
|
||||
@@ -256,8 +384,13 @@ def doc_delete_docs(collection: str, db: str, filter: str, force: bool):
|
||||
|
||||
if not Confirm.ask("[red]Delete matching documents?[/red]"):
|
||||
return
|
||||
|
||||
payload = {"filter": json.loads(filter)}
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/delete", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Deleted documents from '{collection}'")
|
||||
console.print(" Deleted: 0")
|
||||
console.print(f" Deleted: {data.get('deleted_count', 0)}")
|
||||
|
||||
|
||||
@doc_group.command(name="count")
|
||||
@@ -266,7 +399,10 @@ def doc_delete_docs(collection: str, db: str, filter: str, force: bool):
|
||||
@click.option("--query", "-q", default="{}", help="Query filter")
|
||||
def doc_count(collection: str, db: str, query: str):
|
||||
"""Count documents."""
|
||||
console.print(f"[cyan]Count in {db}.{collection}:[/cyan] 0")
|
||||
payload = {"filter": json.loads(query)}
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/count", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[cyan]Count in {db}.{collection}:[/cyan] {data.get('count', 0):,}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -285,13 +421,25 @@ def indexes():
|
||||
@click.option("--db", "-d", default="default")
|
||||
def indexes_list(collection: str, db: str):
|
||||
"""List indexes on a collection."""
|
||||
resp = _request("get", f"/v1/databases/{db}/collections/{collection}/indexes")
|
||||
data = check_response(resp)
|
||||
items = data.get("indexes", [])
|
||||
|
||||
table = Table(title=f"Indexes on '{collection}'", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Keys", style="white")
|
||||
table.add_column("Type", style="yellow")
|
||||
table.add_column("Size", style="dim")
|
||||
|
||||
table.add_row("_id_", "_id: 1", "default", "12 KB")
|
||||
for idx in items:
|
||||
keys = idx.get("keys", idx.get("key", {}))
|
||||
keys_str = ", ".join(f"{k}: {v}" for k, v in keys.items()) if isinstance(keys, dict) else str(keys)
|
||||
table.add_row(
|
||||
idx.get("name", "-"),
|
||||
keys_str,
|
||||
idx.get("type", "standard"),
|
||||
idx.get("size", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
@@ -313,7 +461,26 @@ def indexes_create(collection: str, db: str, keys: str, name: str, unique: bool,
|
||||
hanzo doc indexes create logs -k createdAt:1 --ttl 86400
|
||||
hanzo doc indexes create products -k 'category:1,price:-1'
|
||||
"""
|
||||
key_dict = {}
|
||||
for pair in keys.split(","):
|
||||
parts = pair.strip().split(":")
|
||||
key_dict[parts[0]] = int(parts[1]) if len(parts) > 1 else 1
|
||||
|
||||
payload = {"keys": key_dict}
|
||||
if name:
|
||||
payload["name"] = name
|
||||
if unique:
|
||||
payload["unique"] = True
|
||||
if sparse:
|
||||
payload["sparse"] = True
|
||||
if ttl:
|
||||
payload["expire_after_seconds"] = int(ttl)
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/indexes", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Index created on '{collection}'")
|
||||
console.print(f" Name: {data.get('name', name or '-')}")
|
||||
console.print(f" Keys: {keys}")
|
||||
if unique:
|
||||
console.print(" Unique: Yes")
|
||||
@@ -325,6 +492,8 @@ def indexes_create(collection: str, db: str, keys: str, name: str, unique: bool,
|
||||
@click.option("--db", "-d", default="default")
|
||||
def indexes_drop(collection: str, index_name: str, db: str):
|
||||
"""Drop an index."""
|
||||
resp = _request("delete", f"/v1/databases/{db}/collections/{collection}/indexes/{index_name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Index '{index_name}' dropped from '{collection}'")
|
||||
|
||||
|
||||
@@ -338,8 +507,14 @@ def indexes_drop(collection: str, index_name: str, db: str):
|
||||
@click.option("--output", "-o", help="Output path")
|
||||
def doc_backup(name: str, output: str):
|
||||
"""Create database backup."""
|
||||
payload = {"database": name}
|
||||
if output:
|
||||
payload["output"] = output
|
||||
|
||||
resp = _request("post", "/v1/backups", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Backup created for '{name}'")
|
||||
console.print(f" Backup ID: bak_abc123")
|
||||
console.print(f" Backup ID: {data.get('id', '-')}")
|
||||
|
||||
|
||||
@doc_group.command(name="restore")
|
||||
@@ -347,6 +522,12 @@ def doc_backup(name: str, output: str):
|
||||
@click.option("--to", "-t", help="Target database name")
|
||||
def doc_restore(backup_id: str, to: str):
|
||||
"""Restore from backup."""
|
||||
payload = {"backup_id": backup_id}
|
||||
if to:
|
||||
payload["target_database"] = to
|
||||
|
||||
resp = _request("post", "/v1/backups/restore", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Restored from backup '{backup_id}'")
|
||||
|
||||
|
||||
@@ -358,9 +539,34 @@ def doc_restore(backup_id: str, to: str):
|
||||
def doc_users(action: str, db: str, username: str, role: str):
|
||||
"""Manage database users."""
|
||||
if action == "list":
|
||||
console.print(f"[cyan]Users for '{db}':[/cyan]")
|
||||
console.print("[dim]No users found[/dim]")
|
||||
resp = _request("get", f"/v1/databases/{db}/users")
|
||||
data = check_response(resp)
|
||||
users = data.get("users", [])
|
||||
|
||||
table = Table(title=f"Users for '{db}'", box=box.ROUNDED)
|
||||
table.add_column("Username", style="cyan")
|
||||
table.add_column("Role", style="green")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for u in users:
|
||||
table.add_row(u.get("username", ""), u.get("role", "-"), str(u.get("created_at", ""))[:19])
|
||||
|
||||
console.print(table)
|
||||
if not users:
|
||||
console.print("[dim]No users found[/dim]")
|
||||
|
||||
elif action == "create":
|
||||
if not username or not role:
|
||||
raise click.ClickException("--username and --role required for create")
|
||||
resp = _request("post", f"/v1/databases/{db}/users", json={"username": username, "role": role})
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] User '{username}' created with role '{role}'")
|
||||
if data.get("password"):
|
||||
console.print(f" Password: {data['password']}")
|
||||
|
||||
elif action == "delete":
|
||||
if not username:
|
||||
raise click.ClickException("--username required for delete")
|
||||
resp = _request("delete", f"/v1/databases/{db}/users/{username}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] User '{username}' deleted")
|
||||
|
||||
@@ -4,12 +4,23 @@ Unified eventing layer for schemas, routing, replay, and DLQ.
|
||||
Abstracts over streaming/pubsub/mq transports.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
EVENTS_URL = os.getenv("HANZO_EVENTS_URL", "https://events.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(EVENTS_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="events")
|
||||
@@ -67,7 +78,15 @@ def bus():
|
||||
@click.option("--region", "-r", multiple=True, help="Regions for replication")
|
||||
def bus_create(name: str, backend: str, region: tuple):
|
||||
"""Create an event bus."""
|
||||
payload = {"name": name, "backend": backend}
|
||||
if region:
|
||||
payload["regions"] = list(region)
|
||||
|
||||
resp = _request("post", "/v1/buses", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Event bus '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Backend: {backend}")
|
||||
if region:
|
||||
console.print(f" Regions: {', '.join(region)}")
|
||||
@@ -76,6 +95,10 @@ def bus_create(name: str, backend: str, region: tuple):
|
||||
@bus.command(name="list")
|
||||
def bus_list():
|
||||
"""List event buses."""
|
||||
resp = _request("get", "/v1/buses")
|
||||
data = check_response(resp)
|
||||
buses = data.get("buses", data.get("items", []))
|
||||
|
||||
table = Table(title="Event Buses", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Backend", style="white")
|
||||
@@ -83,22 +106,42 @@ def bus_list():
|
||||
table.add_column("Routes", style="yellow")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for b in buses:
|
||||
status = b.get("status", "unknown")
|
||||
style = "green" if status == "running" else "yellow"
|
||||
table.add_row(
|
||||
b.get("name", ""),
|
||||
b.get("backend", "-"),
|
||||
str(b.get("stream_count", 0)),
|
||||
str(b.get("route_count", 0)),
|
||||
f"[{style}]● {status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No event buses found. Create one with 'hanzo events bus create'[/dim]")
|
||||
if not buses:
|
||||
console.print("[dim]No event buses found. Create one with 'hanzo events bus create'[/dim]")
|
||||
|
||||
|
||||
@bus.command(name="describe")
|
||||
@click.argument("name")
|
||||
def bus_describe(name: str):
|
||||
"""Show event bus details."""
|
||||
resp = _request("get", f"/v1/buses/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "unknown")
|
||||
style = "green" if status == "running" else "yellow"
|
||||
regions = data.get("regions", [])
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Backend:[/cyan] Kafka\n"
|
||||
f"[cyan]Streams:[/cyan] 12\n"
|
||||
f"[cyan]Routes:[/cyan] 8\n"
|
||||
f"[cyan]Events/day:[/cyan] 1.2M\n"
|
||||
f"[cyan]Regions:[/cyan] us-east-1, eu-west-1",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Backend:[/cyan] {data.get('backend', '-')}\n"
|
||||
f"[cyan]Status:[/cyan] [{style}]● {status}[/{style}]\n"
|
||||
f"[cyan]Streams:[/cyan] {data.get('stream_count', 0)}\n"
|
||||
f"[cyan]Routes:[/cyan] {data.get('route_count', 0)}\n"
|
||||
f"[cyan]Events/day:[/cyan] {data.get('events_per_day', 0):,}\n"
|
||||
f"[cyan]Regions:[/cyan] {', '.join(regions) if regions else '-'}",
|
||||
title="Event Bus Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -115,6 +158,9 @@ def bus_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete event bus '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/buses/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Event bus '{name}' deleted")
|
||||
|
||||
|
||||
@@ -136,9 +182,20 @@ def schema():
|
||||
@click.option("--version", "-v", help="Schema version")
|
||||
def schema_register(name: str, file: str, fmt: str, version: str):
|
||||
"""Register an event schema."""
|
||||
payload = {"name": name, "format": fmt}
|
||||
if version:
|
||||
payload["version"] = version
|
||||
if file:
|
||||
from pathlib import Path
|
||||
|
||||
payload["definition"] = Path(file).read_text()
|
||||
|
||||
resp = _request("post", "/v1/schemas", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Schema '{name}' registered")
|
||||
console.print(f" Format: {fmt}")
|
||||
console.print(f" Version: {version or '1'}")
|
||||
console.print(f" Version: {data.get('version', version or '1')}")
|
||||
|
||||
|
||||
@schema.command(name="get")
|
||||
@@ -146,20 +203,49 @@ def schema_register(name: str, file: str, fmt: str, version: str):
|
||||
@click.option("--version", "-v", help="Specific version")
|
||||
def schema_get(name: str, version: str):
|
||||
"""Get schema definition."""
|
||||
console.print(f"[dim]Schema '{name}' not found[/dim]")
|
||||
params = {}
|
||||
if version:
|
||||
params["version"] = version
|
||||
|
||||
resp = _request("get", f"/v1/schemas/{name}", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Format:[/cyan] {data.get('format', '-')}\n"
|
||||
f"[cyan]Version:[/cyan] {data.get('version', '-')}\n"
|
||||
f"[cyan]Definition:[/cyan]\n{data.get('definition', '{}')}",
|
||||
title="Schema",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@schema.command(name="list")
|
||||
def schema_list():
|
||||
"""List all schemas."""
|
||||
resp = _request("get", "/v1/schemas")
|
||||
data = check_response(resp)
|
||||
schemas = data.get("schemas", data.get("items", []))
|
||||
|
||||
table = Table(title="Event Schemas", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Format", style="white")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("Updated", style="dim")
|
||||
|
||||
for s in schemas:
|
||||
table.add_row(
|
||||
s.get("name", ""),
|
||||
s.get("format", "-"),
|
||||
str(s.get("version", "-")),
|
||||
str(s.get("updated_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No schemas found[/dim]")
|
||||
if not schemas:
|
||||
console.print("[dim]No schemas found[/dim]")
|
||||
|
||||
|
||||
@schema.command(name="validate")
|
||||
@@ -168,7 +254,25 @@ def schema_list():
|
||||
@click.option("--data", "-d", help="Event JSON data")
|
||||
def schema_validate(schema: str, file: str, data: str):
|
||||
"""Validate event against schema."""
|
||||
console.print(f"[green]✓[/green] Event is valid against schema '{schema}'")
|
||||
if file:
|
||||
from pathlib import Path
|
||||
|
||||
event_data = Path(file).read_text()
|
||||
elif data:
|
||||
event_data = data
|
||||
else:
|
||||
raise click.ClickException("Provide --file or --data")
|
||||
|
||||
payload = {"schema": schema, "event": json.loads(event_data)}
|
||||
resp = _request("post", "/v1/schemas/validate", json=payload)
|
||||
result = check_response(resp)
|
||||
|
||||
if result.get("valid", True):
|
||||
console.print(f"[green]✓[/green] Event is valid against schema '{schema}'")
|
||||
else:
|
||||
console.print(f"[red]✗[/red] Validation failed:")
|
||||
for err in result.get("errors", []):
|
||||
console.print(f" - {err}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -190,6 +294,13 @@ def stream():
|
||||
@click.option("--retention", "-r", default="7d", help="Retention period")
|
||||
def stream_create(name: str, bus: str, schema: str, partitions: int, retention: str):
|
||||
"""Create an event stream."""
|
||||
payload = {"name": name, "partitions": partitions, "retention": retention}
|
||||
if schema:
|
||||
payload["schema"] = schema
|
||||
|
||||
resp = _request("post", f"/v1/buses/{bus}/streams", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Stream '{name}' created")
|
||||
console.print(f" Bus: {bus}")
|
||||
console.print(f" Partitions: {partitions}")
|
||||
@@ -202,6 +313,11 @@ def stream_create(name: str, bus: str, schema: str, partitions: int, retention:
|
||||
@click.option("--bus", "-b", help="Filter by bus")
|
||||
def stream_list(bus: str):
|
||||
"""List event streams."""
|
||||
path = f"/v1/buses/{bus}/streams" if bus else "/v1/streams"
|
||||
resp = _request("get", path)
|
||||
data = check_response(resp)
|
||||
streams = data.get("streams", data.get("items", []))
|
||||
|
||||
table = Table(title="Event Streams", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Bus", style="white")
|
||||
@@ -209,23 +325,36 @@ def stream_list(bus: str):
|
||||
table.add_column("Retention", style="yellow")
|
||||
table.add_column("Events/day", style="dim")
|
||||
|
||||
for s in streams:
|
||||
table.add_row(
|
||||
s.get("name", ""),
|
||||
s.get("bus", "-"),
|
||||
str(s.get("partitions", 0)),
|
||||
s.get("retention", "-"),
|
||||
str(s.get("events_per_day", 0)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No streams found[/dim]")
|
||||
if not streams:
|
||||
console.print("[dim]No streams found[/dim]")
|
||||
|
||||
|
||||
@stream.command(name="describe")
|
||||
@click.argument("name")
|
||||
def stream_describe(name: str):
|
||||
"""Show stream details."""
|
||||
resp = _request("get", f"/v1/streams/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Stream:[/cyan] {name}\n"
|
||||
f"[cyan]Bus:[/cyan] default\n"
|
||||
f"[cyan]Partitions:[/cyan] 3\n"
|
||||
f"[cyan]Retention:[/cyan] 7 days\n"
|
||||
f"[cyan]Schema:[/cyan] order.created.v1\n"
|
||||
f"[cyan]Events/day:[/cyan] 50,000\n"
|
||||
f"[cyan]Consumers:[/cyan] 4",
|
||||
f"[cyan]Stream:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Bus:[/cyan] {data.get('bus', '-')}\n"
|
||||
f"[cyan]Partitions:[/cyan] {data.get('partitions', 0)}\n"
|
||||
f"[cyan]Retention:[/cyan] {data.get('retention', '-')}\n"
|
||||
f"[cyan]Schema:[/cyan] {data.get('schema', '-')}\n"
|
||||
f"[cyan]Events/day:[/cyan] {data.get('events_per_day', 0):,}\n"
|
||||
f"[cyan]Consumers:[/cyan] {data.get('consumer_count', 0)}",
|
||||
title="Stream Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -242,6 +371,9 @@ def stream_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete stream '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/streams/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Stream '{name}' deleted")
|
||||
|
||||
|
||||
@@ -271,6 +403,15 @@ def route_create(name: str, from_stream: str, to: str, filter: str, transform: s
|
||||
hanzo events route create etl --from users --to task:sync-db
|
||||
hanzo events route create webhook --from payments --to webhook:https://...
|
||||
"""
|
||||
payload = {"name": name, "source": from_stream, "target": to}
|
||||
if filter:
|
||||
payload["filter"] = filter
|
||||
if transform:
|
||||
payload["transform"] = transform
|
||||
|
||||
resp = _request("post", "/v1/routes", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Route '{name}' created")
|
||||
console.print(f" From: {from_stream}")
|
||||
console.print(f" To: {to}")
|
||||
@@ -282,6 +423,14 @@ def route_create(name: str, from_stream: str, to: str, filter: str, transform: s
|
||||
@click.option("--stream", "-s", help="Filter by stream")
|
||||
def route_list(stream: str):
|
||||
"""List event routes."""
|
||||
params = {}
|
||||
if stream:
|
||||
params["stream"] = stream
|
||||
|
||||
resp = _request("get", "/v1/routes", params=params)
|
||||
data = check_response(resp)
|
||||
routes = data.get("routes", data.get("items", []))
|
||||
|
||||
table = Table(title="Event Routes", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("From", style="white")
|
||||
@@ -289,21 +438,50 @@ def route_list(stream: str):
|
||||
table.add_column("Filter", style="dim")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for r in routes:
|
||||
status = r.get("status", "active")
|
||||
style = "green" if status == "active" else "yellow"
|
||||
table.add_row(
|
||||
r.get("name", ""),
|
||||
r.get("source", "-"),
|
||||
r.get("target", "-"),
|
||||
r.get("filter", "-") or "-",
|
||||
f"[{style}]{status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No routes found[/dim]")
|
||||
if not routes:
|
||||
console.print("[dim]No routes found[/dim]")
|
||||
|
||||
|
||||
@route.command(name="describe")
|
||||
@click.argument("name")
|
||||
def route_describe(name: str):
|
||||
"""Show route details."""
|
||||
console.print(f"[dim]Route '{name}' not found[/dim]")
|
||||
resp = _request("get", f"/v1/routes/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Source:[/cyan] {data.get('source', '-')}\n"
|
||||
f"[cyan]Target:[/cyan] {data.get('target', '-')}\n"
|
||||
f"[cyan]Filter:[/cyan] {data.get('filter', '-')}\n"
|
||||
f"[cyan]Transform:[/cyan] {data.get('transform', '-')}\n"
|
||||
f"[cyan]Status:[/cyan] {data.get('status', '-')}\n"
|
||||
f"[cyan]Events routed:[/cyan] {data.get('events_routed', 0):,}",
|
||||
title="Route Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@route.command(name="delete")
|
||||
@click.argument("name")
|
||||
def route_delete(name: str):
|
||||
"""Delete an event route."""
|
||||
resp = _request("delete", f"/v1/routes/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Route '{name}' deleted")
|
||||
|
||||
|
||||
@@ -311,6 +489,8 @@ def route_delete(name: str):
|
||||
@click.argument("name")
|
||||
def route_pause(name: str):
|
||||
"""Pause an event route."""
|
||||
resp = _request("post", f"/v1/routes/{name}/pause")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Route '{name}' paused")
|
||||
|
||||
|
||||
@@ -318,6 +498,8 @@ def route_pause(name: str):
|
||||
@click.argument("name")
|
||||
def route_resume(name: str):
|
||||
"""Resume an event route."""
|
||||
resp = _request("post", f"/v1/routes/{name}/resume")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Route '{name}' resumed")
|
||||
|
||||
|
||||
@@ -333,9 +515,24 @@ def route_resume(name: str):
|
||||
@click.option("--key", "-k", help="Partition key")
|
||||
def events_publish(stream: str, data: str, file: str, key: str):
|
||||
"""Publish an event to a stream."""
|
||||
event_id = "evt_abc123"
|
||||
if file:
|
||||
from pathlib import Path
|
||||
|
||||
event_data = json.loads(Path(file).read_text())
|
||||
elif data:
|
||||
event_data = json.loads(data)
|
||||
else:
|
||||
raise click.ClickException("Provide --data or --file")
|
||||
|
||||
payload = {"data": event_data}
|
||||
if key:
|
||||
payload["key"] = key
|
||||
|
||||
resp = _request("post", f"/v1/streams/{stream}/publish", json=payload)
|
||||
result = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Event published to '{stream}'")
|
||||
console.print(f" Event ID: {event_id}")
|
||||
console.print(f" Event ID: {result.get('event_id', result.get('id', '-'))}")
|
||||
if key:
|
||||
console.print(f" Key: {key}")
|
||||
|
||||
@@ -347,8 +544,27 @@ def events_publish(stream: str, data: str, file: str, key: str):
|
||||
@click.option("--limit", "-n", type=int, help="Max events")
|
||||
def events_tail(stream: str, from_pos: str, filter: str, limit: int):
|
||||
"""Tail events from a stream."""
|
||||
console.print(f"[cyan]Tailing stream '{stream}' from {from_pos}...[/cyan]")
|
||||
console.print("[dim]Press Ctrl+C to stop[/dim]")
|
||||
params = {"position": from_pos}
|
||||
if filter:
|
||||
params["filter"] = filter
|
||||
if limit:
|
||||
params["limit"] = limit
|
||||
|
||||
resp = _request("get", f"/v1/streams/{stream}/tail", params=params)
|
||||
data = check_response(resp)
|
||||
events = data.get("events", [])
|
||||
|
||||
for evt in events:
|
||||
console.print(
|
||||
f"[dim]{evt.get('timestamp', '')}[/dim] "
|
||||
f"[cyan]{evt.get('id', '')}[/cyan] "
|
||||
f"{json.dumps(evt.get('data', {}), default=str)}"
|
||||
)
|
||||
|
||||
if not events:
|
||||
console.print(f"[dim]No events in '{stream}' (position: {from_pos})[/dim]")
|
||||
elif not limit:
|
||||
console.print("[dim]Press Ctrl+C to stop[/dim]")
|
||||
|
||||
|
||||
@events_group.command(name="replay")
|
||||
@@ -359,13 +575,24 @@ def events_tail(stream: str, from_pos: str, filter: str, limit: int):
|
||||
@click.option("--dry-run", is_flag=True, help="Show what would be replayed")
|
||||
def events_replay(stream: str, from_pos: str, to_pos: str, target: str, dry_run: bool):
|
||||
"""Replay events from a stream."""
|
||||
payload = {"stream": stream, "from": from_pos}
|
||||
if to_pos:
|
||||
payload["to"] = to_pos
|
||||
if target:
|
||||
payload["target"] = target
|
||||
if dry_run:
|
||||
console.print("[dim]Dry run - no events replayed[/dim]")
|
||||
return
|
||||
console.print(f"[cyan]Replaying events from '{stream}'...[/cyan]")
|
||||
console.print(f" From: {from_pos}")
|
||||
console.print(f" To: {to_pos or 'latest'}")
|
||||
console.print("[green]✓[/green] Replay complete")
|
||||
payload["dry_run"] = True
|
||||
|
||||
resp = _request("post", "/v1/replay", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[dim]Dry run: {data.get('event_count', 0)} events would be replayed[/dim]")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Replay complete")
|
||||
console.print(f" Events replayed: {data.get('replayed', 0):,}")
|
||||
if data.get("duration"):
|
||||
console.print(f" Duration: {data['duration']}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -384,6 +611,14 @@ def dlq():
|
||||
@click.option("--limit", "-n", default=20, help="Max events")
|
||||
def dlq_list(stream: str, limit: int):
|
||||
"""List dead letter events."""
|
||||
params = {"limit": limit}
|
||||
if stream:
|
||||
params["stream"] = stream
|
||||
|
||||
resp = _request("get", "/v1/dlq", params=params)
|
||||
data = check_response(resp)
|
||||
events = data.get("events", data.get("items", []))
|
||||
|
||||
table = Table(title="Dead Letter Queue", box=box.ROUNDED)
|
||||
table.add_column("Event ID", style="cyan")
|
||||
table.add_column("Stream", style="white")
|
||||
@@ -391,8 +626,18 @@ def dlq_list(stream: str, limit: int):
|
||||
table.add_column("Attempts", style="yellow")
|
||||
table.add_column("Failed At", style="dim")
|
||||
|
||||
for e in events:
|
||||
table.add_row(
|
||||
str(e.get("event_id", e.get("id", "")))[:24],
|
||||
e.get("stream", "-"),
|
||||
str(e.get("error", "-"))[:40],
|
||||
str(e.get("attempts", 0)),
|
||||
str(e.get("failed_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No dead letter events[/dim]")
|
||||
if not events:
|
||||
console.print("[dim]No dead letter events[/dim]")
|
||||
|
||||
|
||||
@dlq.command(name="retry")
|
||||
@@ -401,12 +646,19 @@ def dlq_list(stream: str, limit: int):
|
||||
@click.option("--all", "retry_all", is_flag=True, help="Retry all DLQ events")
|
||||
def dlq_retry(stream: str, event_id: str, retry_all: bool):
|
||||
"""Retry dead letter events."""
|
||||
payload = {}
|
||||
if retry_all:
|
||||
console.print("[green]✓[/green] Retrying all DLQ events")
|
||||
payload["all"] = True
|
||||
elif event_id:
|
||||
console.print(f"[green]✓[/green] Retrying event '{event_id}'")
|
||||
payload["event_id"] = event_id
|
||||
elif stream:
|
||||
console.print(f"[green]✓[/green] Retrying DLQ events for stream '{stream}'")
|
||||
payload["stream"] = stream
|
||||
else:
|
||||
raise click.ClickException("Provide --event-id, --stream, or --all")
|
||||
|
||||
resp = _request("post", "/v1/dlq/retry", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Retried {data.get('retried', 0)} event(s)")
|
||||
|
||||
|
||||
@dlq.command(name="purge")
|
||||
@@ -419,4 +671,11 @@ def dlq_purge(stream: str, force: bool):
|
||||
|
||||
if not Confirm.ask("[red]Purge DLQ events?[/red]"):
|
||||
return
|
||||
console.print("[green]✓[/green] DLQ purged")
|
||||
|
||||
payload = {}
|
||||
if stream:
|
||||
payload["stream"] = stream
|
||||
|
||||
resp = _request("post", "/v1/dlq/purge", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Purged {data.get('purged', 0)} DLQ event(s)")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Build and deploy LLM applications with a visual interface (Langflow-compatible).
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
FLOW_URL = os.getenv("HANZO_FLOW_URL", "https://flow.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(FLOW_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="flow")
|
||||
@@ -58,20 +69,38 @@ def flow_create(name: str, template: str, description: str):
|
||||
hanzo flow create rag-assistant --template rag
|
||||
hanzo flow create summarizer --template chain
|
||||
"""
|
||||
body = {"name": name}
|
||||
if template:
|
||||
body["template"] = template
|
||||
if description:
|
||||
body["description"] = description
|
||||
|
||||
resp = _request("post", "/v1/flows", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Flow '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
if template:
|
||||
console.print(f" Template: {template}")
|
||||
console.print()
|
||||
console.print("Next steps:")
|
||||
console.print(" 1. [cyan]hanzo flow dev[/cyan] - Open visual editor")
|
||||
console.print(" 2. Add components to your flow")
|
||||
console.print(" 3. [cyan]hanzo flow deploy {name}[/cyan] - Deploy to production")
|
||||
console.print(f" 3. [cyan]hanzo flow deploy {name}[/cyan] - Deploy to production")
|
||||
|
||||
|
||||
@flow_group.command(name="list")
|
||||
@click.option("--status", type=click.Choice(["deployed", "draft", "all"]), default="all")
|
||||
def flow_list(status: str):
|
||||
"""List LLM flows."""
|
||||
params = {}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
|
||||
resp = _request("get", "/v1/flows", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("flows", data.get("items", []))
|
||||
|
||||
table = Table(title="LLM Flows", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Components", style="green")
|
||||
@@ -79,23 +108,41 @@ def flow_list(status: str):
|
||||
table.add_column("Endpoint", style="white")
|
||||
table.add_column("Updated", style="dim")
|
||||
|
||||
for f in items:
|
||||
f_status = f.get("status", "draft")
|
||||
style = "green" if f_status == "deployed" else "yellow"
|
||||
table.add_row(
|
||||
f.get("name", ""),
|
||||
str(f.get("component_count", 0)),
|
||||
f"[{style}]{f_status}[/{style}]",
|
||||
f.get("endpoint", "-"),
|
||||
str(f.get("updated_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No flows found. Create one with 'hanzo flow create'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No flows found. Create one with 'hanzo flow create'[/dim]")
|
||||
|
||||
|
||||
@flow_group.command(name="describe")
|
||||
@click.argument("name")
|
||||
def flow_describe(name: str):
|
||||
"""Show flow details."""
|
||||
resp = _request("get", f"/v1/flows/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "draft")
|
||||
status_style = "green" if status == "deployed" else "yellow"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Status:[/cyan] [green]● Deployed[/green]\n"
|
||||
f"[cyan]Components:[/cyan] 5\n"
|
||||
f"[cyan]Endpoint:[/cyan] https://flow.hanzo.ai/{name}\n"
|
||||
f"[cyan]API Key:[/cyan] ****\n"
|
||||
f"[cyan]Calls (24h):[/cyan] 1,234\n"
|
||||
f"[cyan]Avg Latency:[/cyan] 850ms",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Status:[/cyan] [{status_style}]{status}[/{status_style}]\n"
|
||||
f"[cyan]Components:[/cyan] {data.get('component_count', 0)}\n"
|
||||
f"[cyan]Endpoint:[/cyan] {data.get('endpoint', '-')}\n"
|
||||
f"[cyan]Calls (24h):[/cyan] {data.get('calls_24h', 0):,}\n"
|
||||
f"[cyan]Avg Latency:[/cyan] {data.get('avg_latency_ms', 0)}ms\n"
|
||||
f"[cyan]Created:[/cyan] {str(data.get('created_at', ''))[:19]}",
|
||||
title="Flow Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -116,14 +163,24 @@ def flow_run(name: str, input_data: str, stream: bool, verbose: bool):
|
||||
hanzo flow run rag -i '{"query": "How do I reset my password?"}'
|
||||
hanzo flow run summarizer -i @document.txt --stream
|
||||
"""
|
||||
console.print(f"[cyan]Running flow '{name}'...[/cyan]")
|
||||
body = {"input": input_data}
|
||||
if stream:
|
||||
body["stream"] = True
|
||||
if verbose:
|
||||
console.print(" [dim]→ Input processed[/dim]")
|
||||
console.print(" [dim]→ LLM called[/dim]")
|
||||
console.print(" [dim]→ Output generated[/dim]")
|
||||
body["verbose"] = True
|
||||
|
||||
console.print(f"[cyan]Running flow '{name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/flows/{name}/run", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
if verbose and data.get("steps"):
|
||||
for step in data["steps"]:
|
||||
console.print(f" [dim]→ {step.get('name', 'step')}: {step.get('status', 'done')}[/dim]")
|
||||
|
||||
console.print()
|
||||
console.print("[green]Output:[/green]")
|
||||
console.print("[dim]<flow output would appear here>[/dim]")
|
||||
output = data.get("output", data.get("result", ""))
|
||||
console.print(str(output))
|
||||
|
||||
|
||||
@flow_group.command(name="delete")
|
||||
@@ -136,6 +193,9 @@ def flow_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete flow '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/flows/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flow '{name}' deleted")
|
||||
|
||||
|
||||
@@ -145,7 +205,16 @@ def flow_delete(name: str, force: bool):
|
||||
@click.option("--format", "fmt", type=click.Choice(["json", "yaml"]), default="json")
|
||||
def flow_export(name: str, output: str, fmt: str):
|
||||
"""Export flow definition."""
|
||||
resp = _request("get", f"/v1/flows/{name}/export", params={"format": fmt})
|
||||
data = check_response(resp)
|
||||
|
||||
out_file = output or f"{name}.{fmt}"
|
||||
with open(out_file, "w") as f:
|
||||
if fmt == "json":
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
else:
|
||||
f.write(str(data.get("yaml", data.get("definition", ""))))
|
||||
|
||||
console.print(f"[green]✓[/green] Flow exported to '{out_file}'")
|
||||
|
||||
|
||||
@@ -154,8 +223,17 @@ def flow_export(name: str, output: str, fmt: str):
|
||||
@click.option("--name", "-n", help="Override flow name")
|
||||
def flow_import(file: str, name: str):
|
||||
"""Import flow from file."""
|
||||
with open(file) as f:
|
||||
definition = json.load(f)
|
||||
|
||||
body = {"definition": definition}
|
||||
if name:
|
||||
body["name"] = name
|
||||
|
||||
console.print(f"[cyan]Importing flow from '{file}'...[/cyan]")
|
||||
console.print(f"[green]✓[/green] Flow imported")
|
||||
resp = _request("post", "/v1/flows/import", json=body)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flow '{data.get('name', name or 'imported')}' imported")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -174,71 +252,52 @@ def components():
|
||||
@click.option("--search", "-s", help="Search components")
|
||||
def components_list(category: str, search: str):
|
||||
"""List available components."""
|
||||
params = {}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if search:
|
||||
params["search"] = search
|
||||
|
||||
resp = _request("get", "/v1/components", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("components", data.get("items", []))
|
||||
|
||||
table = Table(title="Flow Components", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Category", style="white")
|
||||
table.add_column("Description", style="dim")
|
||||
|
||||
components_data = [
|
||||
# LLMs
|
||||
("OpenAI", "LLMs", "GPT-4, GPT-3.5 models"),
|
||||
("Anthropic", "LLMs", "Claude models"),
|
||||
("Ollama", "LLMs", "Local LLM models"),
|
||||
("HuggingFace", "LLMs", "Open source models"),
|
||||
# Embeddings
|
||||
("OpenAI Embeddings", "Embeddings", "text-embedding-3"),
|
||||
("Cohere Embeddings", "Embeddings", "embed-english-v3"),
|
||||
# Vector Stores
|
||||
("Qdrant", "Vector Stores", "Vector database"),
|
||||
("Pinecone", "Vector Stores", "Managed vector DB"),
|
||||
("Chroma", "Vector Stores", "Local vector store"),
|
||||
# Chains
|
||||
("LLM Chain", "Chains", "Simple LLM chain"),
|
||||
("Retrieval QA", "Chains", "RAG chain"),
|
||||
("Conversation", "Chains", "Chat with memory"),
|
||||
# Agents
|
||||
("ReAct Agent", "Agents", "Reasoning + Acting"),
|
||||
("OpenAI Functions", "Agents", "Function calling agent"),
|
||||
# Tools
|
||||
("Web Search", "Tools", "Search the web"),
|
||||
("Calculator", "Tools", "Math operations"),
|
||||
("Python REPL", "Tools", "Execute Python code"),
|
||||
("API Request", "Tools", "Call external APIs"),
|
||||
# Memory
|
||||
("Buffer Memory", "Memory", "Simple conversation memory"),
|
||||
("Summary Memory", "Memory", "Summarized memory"),
|
||||
("Vector Memory", "Memory", "Vector-based memory"),
|
||||
# Loaders
|
||||
("PDF Loader", "Loaders", "Load PDF documents"),
|
||||
("Web Loader", "Loaders", "Load web pages"),
|
||||
("File Loader", "Loaders", "Load text files"),
|
||||
# Output
|
||||
("Text Output", "Output", "Plain text response"),
|
||||
("JSON Output", "Output", "Structured JSON"),
|
||||
("Chat Output", "Output", "Chat message format"),
|
||||
]
|
||||
|
||||
for name, cat, desc in components_data:
|
||||
if category and category.lower() not in cat.lower():
|
||||
continue
|
||||
if search and search.lower() not in name.lower() and search.lower() not in desc.lower():
|
||||
continue
|
||||
table.add_row(name, cat, desc)
|
||||
for c in items:
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
c.get("category", "-"),
|
||||
c.get("description", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No components found[/dim]")
|
||||
|
||||
|
||||
@components.command(name="describe")
|
||||
@click.argument("name")
|
||||
def components_describe(name: str):
|
||||
"""Show component details."""
|
||||
resp = _request("get", f"/v1/components/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
inputs = ", ".join(data.get("inputs", [])) or "-"
|
||||
outputs = ", ".join(data.get("outputs", [])) or "-"
|
||||
config = ", ".join(data.get("config", [])) or "-"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Category:[/cyan] LLMs\n"
|
||||
f"[cyan]Inputs:[/cyan] prompt, model, temperature\n"
|
||||
f"[cyan]Outputs:[/cyan] text, tokens_used\n"
|
||||
f"[cyan]Config:[/cyan] api_key, max_tokens",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Category:[/cyan] {data.get('category', '-')}\n"
|
||||
f"[cyan]Inputs:[/cyan] {inputs}\n"
|
||||
f"[cyan]Outputs:[/cyan] {outputs}\n"
|
||||
f"[cyan]Config:[/cyan] {config}\n"
|
||||
f"[cyan]Description:[/cyan] {data.get('description', '-')}",
|
||||
title="Component Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -261,21 +320,38 @@ def custom():
|
||||
@click.option("--template", "-t", type=click.Choice(["tool", "chain", "agent"]), default="tool")
|
||||
def custom_create(name: str, template: str):
|
||||
"""Create a custom component."""
|
||||
body = {"name": name, "template": template}
|
||||
|
||||
resp = _request("post", "/v1/components/custom", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Custom component '{name}' created")
|
||||
console.print(f" Template: {template}")
|
||||
console.print(f" File: components/{name}.py")
|
||||
console.print(f" File: {data.get('file', f'components/{name}.py')}")
|
||||
|
||||
|
||||
@custom.command(name="list")
|
||||
def custom_list():
|
||||
"""List custom components."""
|
||||
resp = _request("get", "/v1/components/custom")
|
||||
data = check_response(resp)
|
||||
items = data.get("components", data.get("items", []))
|
||||
|
||||
table = Table(title="Custom Components", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Type", style="white")
|
||||
table.add_column("File", style="dim")
|
||||
|
||||
for c in items:
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
c.get("type", "-"),
|
||||
c.get("file", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No custom components found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No custom components found[/dim]")
|
||||
|
||||
|
||||
@custom.command(name="publish")
|
||||
@@ -283,6 +359,8 @@ def custom_list():
|
||||
def custom_publish(name: str):
|
||||
"""Publish component to registry."""
|
||||
console.print(f"[cyan]Publishing component '{name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/components/custom/{name}/publish")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Component published")
|
||||
|
||||
|
||||
@@ -311,15 +389,21 @@ def flow_dev(port: int, flow: str):
|
||||
def flow_deploy(name: str, env: str):
|
||||
"""Deploy flow to production."""
|
||||
console.print(f"[cyan]Deploying flow '{name}' to {env}...[/cyan]")
|
||||
resp = _request("post", f"/v1/flows/{name}/deploy", json={"environment": env})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Flow deployed")
|
||||
console.print(f" Endpoint: https://flow.hanzo.ai/{name}")
|
||||
console.print(f" API Key: sk-flow-***")
|
||||
console.print(f" Endpoint: {data.get('endpoint', f'{FLOW_URL}/{name}')}")
|
||||
if data.get("api_key"):
|
||||
console.print(f" API Key: {data['api_key'][:12]}***")
|
||||
|
||||
|
||||
@flow_group.command(name="undeploy")
|
||||
@click.argument("name")
|
||||
def flow_undeploy(name: str):
|
||||
"""Undeploy a flow."""
|
||||
resp = _request("post", f"/v1/flows/{name}/undeploy")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flow '{name}' undeployed")
|
||||
|
||||
|
||||
@@ -338,14 +422,27 @@ def api():
|
||||
@click.argument("flow_name")
|
||||
def api_keys(flow_name: str):
|
||||
"""List API keys for a flow."""
|
||||
resp = _request("get", f"/v1/flows/{flow_name}/keys")
|
||||
data = check_response(resp)
|
||||
items = data.get("keys", data.get("items", []))
|
||||
|
||||
table = Table(title=f"API Keys for '{flow_name}'", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Key", style="white")
|
||||
table.add_column("Created", style="dim")
|
||||
table.add_column("Last Used", style="dim")
|
||||
|
||||
for k in items:
|
||||
table.add_row(
|
||||
k.get("name", ""),
|
||||
k.get("key_prefix", "****"),
|
||||
str(k.get("created_at", ""))[:19],
|
||||
str(k.get("last_used_at", "-"))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No API keys found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No API keys found[/dim]")
|
||||
|
||||
|
||||
@api.command(name="create-key")
|
||||
@@ -353,9 +450,12 @@ def api_keys(flow_name: str):
|
||||
@click.option("--name", "-n", default="default", help="Key name")
|
||||
def api_create_key(flow_name: str, name: str):
|
||||
"""Create API key for a flow."""
|
||||
resp = _request("post", f"/v1/flows/{flow_name}/keys", json={"name": name})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] API key created for '{flow_name}'")
|
||||
console.print(f" Name: {name}")
|
||||
console.print(f" Key: sk-flow-xxxxxxxxxxxx")
|
||||
console.print(f" Key: {data.get('key', '-')}")
|
||||
console.print("[yellow]Save this key - it won't be shown again[/yellow]")
|
||||
|
||||
|
||||
@@ -364,6 +464,8 @@ def api_create_key(flow_name: str, name: str):
|
||||
@click.argument("key_name")
|
||||
def api_revoke_key(flow_name: str, key_name: str):
|
||||
"""Revoke an API key."""
|
||||
resp = _request("delete", f"/v1/flows/{flow_name}/keys/{key_name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] API key '{key_name}' revoked")
|
||||
|
||||
|
||||
@@ -373,9 +475,14 @@ def api_revoke_key(flow_name: str, key_name: str):
|
||||
def api_test(flow_name: str, input_data: str):
|
||||
"""Test flow API endpoint."""
|
||||
console.print(f"[cyan]Testing API for '{flow_name}'...[/cyan]")
|
||||
console.print("[green]✓[/green] API responded successfully")
|
||||
console.print(" Status: 200")
|
||||
console.print(" Latency: 850ms")
|
||||
resp = _request("post", f"/v1/flows/{flow_name}/test", json={"input": input_data})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] API responded successfully")
|
||||
console.print(f" Status: {data.get('status_code', 200)}")
|
||||
console.print(f" Latency: {data.get('latency_ms', '-')}ms")
|
||||
if data.get("output"):
|
||||
console.print(f" Output: {json.dumps(data['output'], default=str)[:200]}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -406,26 +513,25 @@ def templates():
|
||||
@templates.command(name="list")
|
||||
def templates_list():
|
||||
"""List available templates."""
|
||||
resp = _request("get", "/v1/templates")
|
||||
data = check_response(resp)
|
||||
items = data.get("templates", data.get("items", []))
|
||||
|
||||
table = Table(title="Flow Templates", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Description", style="white")
|
||||
table.add_column("Components", style="dim")
|
||||
|
||||
templates_data = [
|
||||
("chatbot", "Simple chatbot with memory", "LLM, Memory, Output"),
|
||||
("rag", "Retrieval Augmented Generation", "Loader, Embeddings, VectorStore, LLM"),
|
||||
("agent", "ReAct agent with tools", "Agent, Tools, LLM"),
|
||||
("chain", "Sequential LLM chain", "LLM, Chain, Output"),
|
||||
("summarizer", "Document summarizer", "Loader, LLM, Output"),
|
||||
("qa", "Question answering", "Loader, LLM, Output"),
|
||||
("translator", "Multi-language translator", "LLM, Output"),
|
||||
("code-assistant", "Code generation assistant", "LLM, Python REPL, Output"),
|
||||
]
|
||||
|
||||
for name, desc, comps in templates_data:
|
||||
table.add_row(name, desc, comps)
|
||||
for t in items:
|
||||
table.add_row(
|
||||
t.get("name", ""),
|
||||
t.get("description", "-"),
|
||||
t.get("components", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No templates found[/dim]")
|
||||
|
||||
|
||||
@templates.command(name="use")
|
||||
@@ -434,7 +540,11 @@ def templates_list():
|
||||
def templates_use(template: str, name: str):
|
||||
"""Create flow from template."""
|
||||
console.print(f"[cyan]Creating flow '{name}' from template '{template}'...[/cyan]")
|
||||
resp = _request("post", "/v1/flows/from-template", json={"template": template, "name": name})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Flow created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print()
|
||||
console.print("Next steps:")
|
||||
console.print(f" 1. [cyan]hanzo flow dev -f {name}[/cyan] - Edit in visual editor")
|
||||
@@ -450,14 +560,29 @@ def templates_use(template: str, name: str):
|
||||
@click.argument("name")
|
||||
def flow_versions(name: str):
|
||||
"""List flow versions."""
|
||||
resp = _request("get", f"/v1/flows/{name}/versions")
|
||||
data = check_response(resp)
|
||||
items = data.get("versions", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Versions of '{name}'", box=box.ROUNDED)
|
||||
table.add_column("Version", style="cyan")
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Created", style="dim")
|
||||
table.add_column("Note", style="dim")
|
||||
|
||||
for v in items:
|
||||
v_status = v.get("status", "inactive")
|
||||
style = "green" if v_status == "active" else "dim"
|
||||
table.add_row(
|
||||
str(v.get("version", "")),
|
||||
f"[{style}]{v_status}[/{style}]",
|
||||
str(v.get("created_at", ""))[:19],
|
||||
v.get("note", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No versions found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No versions found[/dim]")
|
||||
|
||||
|
||||
@flow_group.command(name="rollback")
|
||||
@@ -466,4 +591,6 @@ def flow_versions(name: str):
|
||||
def flow_rollback(name: str, version: str):
|
||||
"""Rollback to a previous version."""
|
||||
console.print(f"[cyan]Rolling back '{name}' to version {version}...[/cyan]")
|
||||
resp = _request("post", f"/v1/flows/{name}/rollback", json={"version": version})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Rolled back successfully")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Deploy and manage serverless functions with automatic scaling.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
FN_URL = os.getenv("HANZO_FN_URL", "https://fn.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(FN_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="function")
|
||||
@@ -72,7 +83,17 @@ def fn_create(name: str, runtime: str, memory: str, timeout: int, region: str, s
|
||||
hanzo fn create handler --from ./src/handler
|
||||
hanzo fn create api --from github.com/user/repo
|
||||
"""
|
||||
body = {"name": name, "runtime": runtime, "memory": int(memory), "timeout": timeout}
|
||||
if region:
|
||||
body["region"] = region
|
||||
if source:
|
||||
body["source"] = source
|
||||
|
||||
resp = _request("post", "/v1/functions", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Function '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Runtime: {runtime}")
|
||||
console.print(f" Memory: {memory}MB")
|
||||
console.print(f" Timeout: {timeout}s")
|
||||
@@ -87,6 +108,16 @@ def fn_create(name: str, runtime: str, memory: str, timeout: int, region: str, s
|
||||
@click.option("--runtime", help="Filter by runtime")
|
||||
def fn_list(region: str, runtime: str):
|
||||
"""List functions."""
|
||||
params = {}
|
||||
if region:
|
||||
params["region"] = region
|
||||
if runtime:
|
||||
params["runtime"] = runtime
|
||||
|
||||
resp = _request("get", "/v1/functions", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("functions", data.get("items", []))
|
||||
|
||||
table = Table(title="Functions", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Runtime", style="white")
|
||||
@@ -95,25 +126,44 @@ def fn_list(region: str, runtime: str):
|
||||
table.add_column("Last Deploy", style="dim")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for f in items:
|
||||
f_status = f.get("status", "inactive")
|
||||
style = "green" if f_status == "active" else "yellow"
|
||||
table.add_row(
|
||||
f.get("name", ""),
|
||||
f.get("runtime", "-"),
|
||||
f"{f.get('memory', 256)}MB",
|
||||
str(f.get("trigger_count", 0)),
|
||||
str(f.get("last_deployed_at", ""))[:19],
|
||||
f"[{style}]{f_status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No functions found. Create one with 'hanzo fn create'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No functions found. Create one with 'hanzo fn create'[/dim]")
|
||||
|
||||
|
||||
@fn_group.command(name="describe")
|
||||
@click.argument("name")
|
||||
def fn_describe(name: str):
|
||||
"""Show function details."""
|
||||
resp = _request("get", f"/v1/functions/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "inactive")
|
||||
status_style = "green" if status == "active" else "yellow"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Runtime:[/cyan] python3.12\n"
|
||||
f"[cyan]Memory:[/cyan] 256 MB\n"
|
||||
f"[cyan]Timeout:[/cyan] 30s\n"
|
||||
f"[cyan]Status:[/cyan] [green]● Active[/green]\n"
|
||||
f"[cyan]Triggers:[/cyan] 2 (http, cron)\n"
|
||||
f"[cyan]Invocations (24h):[/cyan] 1,234\n"
|
||||
f"[cyan]Avg Duration:[/cyan] 45ms\n"
|
||||
f"[cyan]Endpoint:[/cyan] https://fn.hanzo.ai/{name}",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Runtime:[/cyan] {data.get('runtime', '-')}\n"
|
||||
f"[cyan]Memory:[/cyan] {data.get('memory', 256)} MB\n"
|
||||
f"[cyan]Timeout:[/cyan] {data.get('timeout', 30)}s\n"
|
||||
f"[cyan]Status:[/cyan] [{status_style}]{status}[/{status_style}]\n"
|
||||
f"[cyan]Triggers:[/cyan] {data.get('trigger_count', 0)}\n"
|
||||
f"[cyan]Invocations (24h):[/cyan] {data.get('invocations_24h', 0):,}\n"
|
||||
f"[cyan]Avg Duration:[/cyan] {data.get('avg_duration_ms', 0)}ms\n"
|
||||
f"[cyan]Endpoint:[/cyan] {data.get('endpoint', f'{FN_URL}/{name}')}",
|
||||
title="Function Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -136,14 +186,23 @@ def fn_deploy(name: str, source: str, entry: str, build: bool, force: bool):
|
||||
hanzo fn deploy my-api --entry main.handler # Specify entry point
|
||||
hanzo fn deploy my-api --build # Build and deploy
|
||||
"""
|
||||
console.print(f"[cyan]Deploying '{name}'...[/cyan]")
|
||||
body = {}
|
||||
if source:
|
||||
body["source"] = source
|
||||
if entry:
|
||||
body["entry_point"] = entry
|
||||
if build:
|
||||
console.print(" Building...")
|
||||
console.print(" Uploading...")
|
||||
console.print(" Activating...")
|
||||
body["build"] = True
|
||||
if force:
|
||||
body["force"] = True
|
||||
|
||||
console.print(f"[cyan]Deploying '{name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/functions/{name}/deploy", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Function '{name}' deployed")
|
||||
console.print(f" Version: v1")
|
||||
console.print(f" Endpoint: https://fn.hanzo.ai/{name}")
|
||||
console.print(f" Version: {data.get('version', '-')}")
|
||||
console.print(f" Endpoint: {data.get('endpoint', f'{FN_URL}/{name}')}")
|
||||
|
||||
|
||||
@fn_group.command(name="delete")
|
||||
@@ -156,6 +215,9 @@ def fn_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete function '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/functions/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Function '{name}' deleted")
|
||||
|
||||
|
||||
@@ -201,14 +263,32 @@ def triggers_add(
|
||||
hanzo fn triggers add worker --type queue --queue tasks
|
||||
hanzo fn triggers add upload-handler --type storage --bucket uploads
|
||||
"""
|
||||
body = {"type": trigger_type}
|
||||
if trigger_type == "http":
|
||||
body["path"] = path or "/"
|
||||
body["methods"] = list(method) if method else ["GET"]
|
||||
elif trigger_type == "event":
|
||||
body["event"] = event
|
||||
body["bus"] = bus or "default"
|
||||
elif trigger_type == "cron":
|
||||
body["schedule"] = schedule
|
||||
elif trigger_type == "queue":
|
||||
body["queue"] = queue
|
||||
elif trigger_type == "storage":
|
||||
body["bucket"] = bucket
|
||||
|
||||
resp = _request("post", f"/v1/functions/{function}/triggers", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Trigger added to '{function}'")
|
||||
console.print(f" Type: {trigger_type}")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
if trigger_type == "http":
|
||||
console.print(f" Path: {path or '/'}")
|
||||
console.print(f" Methods: {', '.join(method) if method else 'GET'}")
|
||||
console.print(f" Path: {body.get('path', '/')}")
|
||||
console.print(f" Methods: {', '.join(body.get('methods', ['GET']))}")
|
||||
elif trigger_type == "event":
|
||||
console.print(f" Event: {event}")
|
||||
console.print(f" Bus: {bus or 'default'}")
|
||||
console.print(f" Bus: {body.get('bus', 'default')}")
|
||||
elif trigger_type == "cron":
|
||||
console.print(f" Schedule: {schedule}")
|
||||
elif trigger_type == "queue":
|
||||
@@ -221,14 +301,30 @@ def triggers_add(
|
||||
@click.argument("function")
|
||||
def triggers_list(function: str):
|
||||
"""List triggers for a function."""
|
||||
resp = _request("get", f"/v1/functions/{function}/triggers")
|
||||
data = check_response(resp)
|
||||
items = data.get("triggers", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Triggers for '{function}'", box=box.ROUNDED)
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Type", style="white")
|
||||
table.add_column("Source", style="green")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for t in items:
|
||||
source = t.get("path", t.get("event", t.get("schedule", t.get("queue", t.get("bucket", "-")))))
|
||||
t_status = t.get("status", "active")
|
||||
style = "green" if t_status == "active" else "yellow"
|
||||
table.add_row(
|
||||
str(t.get("id", ""))[:16],
|
||||
t.get("type", "-"),
|
||||
source,
|
||||
f"[{style}]{t_status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No triggers found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No triggers found[/dim]")
|
||||
|
||||
|
||||
@triggers.command(name="rm")
|
||||
@@ -236,6 +332,8 @@ def triggers_list(function: str):
|
||||
@click.argument("trigger_id")
|
||||
def triggers_rm(function: str, trigger_id: str):
|
||||
"""Remove a trigger."""
|
||||
resp = _request("delete", f"/v1/functions/{function}/triggers/{trigger_id}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Trigger '{trigger_id}' removed from '{function}'")
|
||||
|
||||
|
||||
@@ -260,15 +358,35 @@ def fn_invoke(name: str, data: str, payload_file: str, async_invoke: bool, tail:
|
||||
hanzo fn invoke my-api -f payload.json
|
||||
hanzo fn invoke my-api --async
|
||||
"""
|
||||
body = {}
|
||||
if data:
|
||||
body["data"] = json.loads(data)
|
||||
elif payload_file:
|
||||
with open(payload_file) as f:
|
||||
body["data"] = json.load(f)
|
||||
|
||||
if async_invoke:
|
||||
body["async"] = True
|
||||
|
||||
console.print(f"[cyan]Invoking '{name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/functions/{name}/invoke", json=body)
|
||||
result = check_response(resp)
|
||||
|
||||
if async_invoke:
|
||||
console.print("[green]✓[/green] Function invoked asynchronously")
|
||||
console.print(" Request ID: req_abc123")
|
||||
console.print(f" Request ID: {result.get('request_id', '-')}")
|
||||
else:
|
||||
console.print("[green]✓[/green] Function executed")
|
||||
console.print(" Duration: 45ms")
|
||||
console.print(" Status: 200")
|
||||
console.print(" Response: {}")
|
||||
console.print(f" Duration: {result.get('duration_ms', '-')}ms")
|
||||
console.print(f" Status: {result.get('status_code', '-')}")
|
||||
if result.get("body"):
|
||||
console.print(f" Response: {json.dumps(result['body'], default=str)}")
|
||||
|
||||
if tail and result.get("request_id"):
|
||||
log_resp = _request("get", f"/v1/functions/{name}/logs", params={"request_id": result["request_id"]})
|
||||
log_data = check_response(log_resp)
|
||||
for line in log_data.get("logs", []):
|
||||
console.print(str(line))
|
||||
|
||||
|
||||
@fn_group.command(name="logs")
|
||||
@@ -287,12 +405,34 @@ def fn_logs(name: str, follow: bool, since: str, log_filter: str, limit: int):
|
||||
hanzo fn logs my-api --since 24h # Last 24 hours
|
||||
hanzo fn logs my-api --filter error # Filter for errors
|
||||
"""
|
||||
params = {"since": since, "limit": limit}
|
||||
if follow:
|
||||
params["follow"] = "true"
|
||||
if log_filter:
|
||||
params["filter"] = log_filter
|
||||
|
||||
resp = _request("get", f"/v1/functions/{name}/logs", params=params)
|
||||
data = check_response(resp)
|
||||
lines = data.get("logs", data.get("lines", []))
|
||||
|
||||
if follow:
|
||||
console.print(f"[cyan]Tailing logs for '{name}'...[/cyan]")
|
||||
console.print("[dim]Press Ctrl+C to stop[/dim]")
|
||||
else:
|
||||
console.print(f"[cyan]Logs for '{name}' (last {since}):[/cyan]")
|
||||
console.print("[dim]No log entries found[/dim]")
|
||||
|
||||
for line in lines:
|
||||
if isinstance(line, dict):
|
||||
ts = str(line.get("timestamp", ""))[:19]
|
||||
level = line.get("level", "info")
|
||||
msg = line.get("message", "")
|
||||
style = "red" if level == "error" else "yellow" if level == "warn" else "dim"
|
||||
console.print(f"[dim]{ts}[/dim] [{style}]{level}[/{style}] {msg}")
|
||||
else:
|
||||
console.print(str(line))
|
||||
|
||||
if not lines:
|
||||
console.print("[dim]No log entries found[/dim]")
|
||||
|
||||
|
||||
@fn_group.command(name="stats")
|
||||
@@ -300,16 +440,19 @@ def fn_logs(name: str, follow: bool, since: str, log_filter: str, limit: int):
|
||||
@click.option("--range", "-r", "time_range", default="24h", help="Time range")
|
||||
def fn_stats(name: str, time_range: str):
|
||||
"""Show function statistics."""
|
||||
resp = _request("get", f"/v1/functions/{name}/stats", params={"range": time_range})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Function:[/cyan] {name}\n"
|
||||
f"[cyan]Time Range:[/cyan] {time_range}\n\n"
|
||||
f"[cyan]Invocations:[/cyan] 0\n"
|
||||
f"[cyan]Errors:[/cyan] 0 (0%)\n"
|
||||
f"[cyan]Avg Duration:[/cyan] 0ms\n"
|
||||
f"[cyan]P95 Duration:[/cyan] 0ms\n"
|
||||
f"[cyan]Cold Starts:[/cyan] 0\n"
|
||||
f"[cyan]Memory Peak:[/cyan] 0 MB",
|
||||
f"[cyan]Invocations:[/cyan] {data.get('invocations', 0):,}\n"
|
||||
f"[cyan]Errors:[/cyan] {data.get('errors', 0)} ({data.get('error_rate', 0)}%)\n"
|
||||
f"[cyan]Avg Duration:[/cyan] {data.get('avg_duration_ms', 0)}ms\n"
|
||||
f"[cyan]P95 Duration:[/cyan] {data.get('p95_duration_ms', 0)}ms\n"
|
||||
f"[cyan]Cold Starts:[/cyan] {data.get('cold_starts', 0)}\n"
|
||||
f"[cyan]Memory Peak:[/cyan] {data.get('memory_peak_mb', 0)} MB",
|
||||
title="Function Statistics",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -333,6 +476,8 @@ def env():
|
||||
@click.argument("value")
|
||||
def env_set(function: str, key: str, value: str):
|
||||
"""Set an environment variable."""
|
||||
resp = _request("put", f"/v1/functions/{function}/env/{key}", json={"value": value})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Set {key} for '{function}'")
|
||||
|
||||
|
||||
@@ -342,10 +487,19 @@ def env_set(function: str, key: str, value: str):
|
||||
def env_get(function: str, key: str):
|
||||
"""Get environment variables."""
|
||||
if key:
|
||||
console.print(f"[cyan]{key}=[/cyan] (not set)")
|
||||
resp = _request("get", f"/v1/functions/{function}/env/{key}")
|
||||
data = check_response(resp)
|
||||
console.print(f"[cyan]{key}=[/cyan]{data.get('value', '(not set)')}")
|
||||
else:
|
||||
resp = _request("get", f"/v1/functions/{function}/env")
|
||||
data = check_response(resp)
|
||||
env_vars = data.get("env", data.get("variables", {}))
|
||||
console.print(f"[cyan]Environment for '{function}':[/cyan]")
|
||||
console.print("[dim]No environment variables set[/dim]")
|
||||
if env_vars:
|
||||
for k, v in env_vars.items():
|
||||
console.print(f" {k}={v}")
|
||||
else:
|
||||
console.print("[dim]No environment variables set[/dim]")
|
||||
|
||||
|
||||
@env.command(name="rm")
|
||||
@@ -353,6 +507,8 @@ def env_get(function: str, key: str):
|
||||
@click.argument("key")
|
||||
def env_rm(function: str, key: str):
|
||||
"""Remove an environment variable."""
|
||||
resp = _request("delete", f"/v1/functions/{function}/env/{key}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Removed {key} from '{function}'")
|
||||
|
||||
|
||||
@@ -374,6 +530,12 @@ def secrets_bind(function: str, secret: str, env_name: str):
|
||||
hanzo fn secrets bind my-api db-password
|
||||
hanzo fn secrets bind my-api api-key --as API_KEY
|
||||
"""
|
||||
body = {"secret": secret}
|
||||
if env_name:
|
||||
body["env_name"] = env_name
|
||||
|
||||
resp = _request("post", f"/v1/functions/{function}/secrets", json=body)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Bound secret '{secret}' to '{function}'")
|
||||
if env_name:
|
||||
console.print(f" As: {env_name}")
|
||||
@@ -384,6 +546,8 @@ def secrets_bind(function: str, secret: str, env_name: str):
|
||||
@click.argument("secret")
|
||||
def secrets_unbind(function: str, secret: str):
|
||||
"""Unbind a secret from a function."""
|
||||
resp = _request("delete", f"/v1/functions/{function}/secrets/{secret}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Unbound secret '{secret}' from '{function}'")
|
||||
|
||||
|
||||
@@ -391,13 +555,25 @@ def secrets_unbind(function: str, secret: str):
|
||||
@click.argument("function")
|
||||
def secrets_list(function: str):
|
||||
"""List secrets bound to a function."""
|
||||
resp = _request("get", f"/v1/functions/{function}/secrets")
|
||||
data = check_response(resp)
|
||||
items = data.get("secrets", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Secrets for '{function}'", box=box.ROUNDED)
|
||||
table.add_column("Secret", style="cyan")
|
||||
table.add_column("Env Var", style="white")
|
||||
table.add_column("Version", style="dim")
|
||||
|
||||
for s in items:
|
||||
table.add_row(
|
||||
s.get("secret", ""),
|
||||
s.get("env_name", "-"),
|
||||
str(s.get("version", "-")),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No secrets bound[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No secrets bound[/dim]")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -409,14 +585,29 @@ def secrets_list(function: str):
|
||||
@click.argument("name")
|
||||
def fn_versions(name: str):
|
||||
"""List function versions."""
|
||||
resp = _request("get", f"/v1/functions/{name}/versions")
|
||||
data = check_response(resp)
|
||||
items = data.get("versions", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Versions of '{name}'", box=box.ROUNDED)
|
||||
table.add_column("Version", style="cyan")
|
||||
table.add_column("Deployed", style="white")
|
||||
table.add_column("Traffic", style="green")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for v in items:
|
||||
v_status = v.get("status", "inactive")
|
||||
style = "green" if v_status == "active" else "dim"
|
||||
table.add_row(
|
||||
str(v.get("version", "")),
|
||||
str(v.get("deployed_at", ""))[:19],
|
||||
f"{v.get('traffic', 0)}%",
|
||||
f"[{style}]{v_status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No versions found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No versions found[/dim]")
|
||||
|
||||
|
||||
@fn_group.command(name="rollback")
|
||||
@@ -424,9 +615,14 @@ def fn_versions(name: str):
|
||||
@click.option("--version", "-v", help="Target version")
|
||||
def fn_rollback(name: str, version: str):
|
||||
"""Rollback to a previous version."""
|
||||
target = version or "previous"
|
||||
console.print(f"[cyan]Rolling back '{name}' to {target}...[/cyan]")
|
||||
console.print(f"[green]✓[/green] Rolled back to {target}")
|
||||
body = {}
|
||||
if version:
|
||||
body["version"] = version
|
||||
|
||||
console.print(f"[cyan]Rolling back '{name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/functions/{name}/rollback", json=body)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Rolled back to {data.get('version', version or 'previous')}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -446,9 +642,25 @@ def fn_traffic(name: str, version: tuple):
|
||||
hanzo fn traffic my-api -v v2:100 # 100% to v2
|
||||
"""
|
||||
if version:
|
||||
splits = {}
|
||||
for v in version:
|
||||
ver, weight = v.split(":")
|
||||
splits[ver] = int(weight)
|
||||
|
||||
resp = _request("put", f"/v1/functions/{name}/traffic", json={"splits": splits})
|
||||
check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Traffic configured for '{name}'")
|
||||
for v in version:
|
||||
console.print(f" {v}")
|
||||
else:
|
||||
resp = _request("get", f"/v1/functions/{name}/traffic")
|
||||
data = check_response(resp)
|
||||
splits = data.get("splits", {})
|
||||
|
||||
console.print(f"[cyan]Traffic configuration for '{name}':[/cyan]")
|
||||
console.print("[dim]Single version active (100%)[/dim]")
|
||||
if splits:
|
||||
for ver, weight in splits.items():
|
||||
console.print(f" {ver}: {weight}%")
|
||||
else:
|
||||
console.print("[dim]Single version active (100%)[/dim]")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Product analytics, feature flags, A/B testing, lifecycle messaging.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
SERVICE_URL = os.getenv("HANZO_GROWTH_URL", "https://growth.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(SERVICE_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="growth")
|
||||
@@ -54,14 +65,33 @@ def events():
|
||||
@events.command(name="list")
|
||||
@click.option("--limit", "-n", default=50, help="Number of events")
|
||||
@click.option("--event", "-e", help="Filter by event name")
|
||||
def events_list(limit: int, event: str):
|
||||
@click.option("--user", "-u", help="Filter by user ID")
|
||||
def events_list(limit: int, event: str, user: str):
|
||||
"""List recent events."""
|
||||
params: dict = {"limit": limit}
|
||||
if event:
|
||||
params["event"] = event
|
||||
if user:
|
||||
params["user"] = user
|
||||
resp = _request("get", "/v1/events", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Recent Events", box=box.ROUNDED)
|
||||
table.add_column("Event", style="cyan")
|
||||
table.add_column("User", style="white")
|
||||
table.add_column("Properties", style="dim")
|
||||
table.add_column("Time", style="dim")
|
||||
|
||||
for e in data.get("events", []):
|
||||
props = e.get("properties", {})
|
||||
props_str = ", ".join(f"{k}={v}" for k, v in props.items()) if isinstance(props, dict) else str(props)
|
||||
table.add_row(
|
||||
e.get("event", ""),
|
||||
e.get("user_id", ""),
|
||||
props_str[:60],
|
||||
e.get("timestamp", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -71,17 +101,35 @@ def events_list(limit: int, event: str):
|
||||
@click.option("--props", "-p", help="JSON properties")
|
||||
def events_track(event_name: str, user: str, props: str):
|
||||
"""Track an event."""
|
||||
payload: dict = {"event": event_name}
|
||||
if user:
|
||||
payload["user_id"] = user
|
||||
if props:
|
||||
payload["properties"] = json.loads(props)
|
||||
resp = _request("post", "/v1/events", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Event '{event_name}' tracked")
|
||||
|
||||
|
||||
@events.command(name="schema")
|
||||
def events_schema():
|
||||
"""Show event schema."""
|
||||
resp = _request("get", "/v1/events/schema")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Event Schema", box=box.ROUNDED)
|
||||
table.add_column("Event", style="cyan")
|
||||
table.add_column("Properties", style="white")
|
||||
table.add_column("Count", style="dim")
|
||||
|
||||
for s in data.get("schemas", []):
|
||||
props = ", ".join(s.get("properties", []))
|
||||
table.add_row(
|
||||
s.get("event", ""),
|
||||
props[:60],
|
||||
str(s.get("count", 0)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -99,29 +147,63 @@ def funnels():
|
||||
@funnels.command(name="list")
|
||||
def funnels_list():
|
||||
"""List all funnels."""
|
||||
resp = _request("get", "/v1/funnels")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Funnels", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Steps", style="white")
|
||||
table.add_column("Conversion", style="green")
|
||||
table.add_column("Users", style="dim")
|
||||
|
||||
for f in data.get("funnels", []):
|
||||
conv = f.get("conversion_rate")
|
||||
conv_str = f"{conv:.1f}%" if conv is not None else "-"
|
||||
table.add_row(
|
||||
f.get("name", ""),
|
||||
str(f.get("step_count", 0)),
|
||||
conv_str,
|
||||
str(f.get("user_count", 0)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@funnels.command(name="show")
|
||||
@click.argument("funnel_name")
|
||||
def funnels_show(funnel_name: str):
|
||||
@click.option("--period", "-p", default="30d", help="Time period")
|
||||
def funnels_show(funnel_name: str, period: str):
|
||||
"""Show funnel details."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Funnel:[/cyan] {funnel_name}\n"
|
||||
f"[cyan]Steps:[/cyan] 4\n"
|
||||
f"[cyan]Conversion:[/cyan] 23.5%\n"
|
||||
f"[cyan]Period:[/cyan] Last 30 days",
|
||||
title="Funnel Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
resp = _request("get", f"/v1/funnels/{funnel_name}", params={"period": period})
|
||||
data = check_response(resp)
|
||||
|
||||
info = (
|
||||
f"[cyan]Funnel:[/cyan] {data.get('name', funnel_name)}\n"
|
||||
f"[cyan]Steps:[/cyan] {data.get('step_count', 0)}\n"
|
||||
f"[cyan]Conversion:[/cyan] {data.get('conversion_rate', 0):.1f}%\n"
|
||||
f"[cyan]Period:[/cyan] {period}"
|
||||
)
|
||||
console.print(Panel(info, title="Funnel Details", border_style="cyan"))
|
||||
|
||||
steps = data.get("steps", [])
|
||||
if steps:
|
||||
table = Table(title="Funnel Steps", box=box.ROUNDED)
|
||||
table.add_column("Step", style="cyan")
|
||||
table.add_column("Event", style="white")
|
||||
table.add_column("Users", style="dim")
|
||||
table.add_column("Conversion", style="green")
|
||||
table.add_column("Drop-off", style="red")
|
||||
|
||||
for i, step in enumerate(steps, 1):
|
||||
table.add_row(
|
||||
str(i),
|
||||
step.get("event", ""),
|
||||
str(step.get("users", 0)),
|
||||
f"{step.get('conversion', 0):.1f}%",
|
||||
f"{step.get('dropoff', 0):.1f}%",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@funnels.command(name="create")
|
||||
@@ -129,7 +211,19 @@ def funnels_show(funnel_name: str):
|
||||
@click.option("--steps", "-s", required=True, help="Comma-separated event names")
|
||||
def funnels_create(name: str, steps: str):
|
||||
"""Create a funnel."""
|
||||
console.print(f"[green]✓[/green] Funnel '{name}' created")
|
||||
step_list = [s.strip() for s in steps.split(",")]
|
||||
resp = _request("post", "/v1/funnels", json={"name": name, "steps": step_list})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Funnel '{name}' created with {len(step_list)} steps")
|
||||
|
||||
|
||||
@funnels.command(name="delete")
|
||||
@click.argument("funnel_name")
|
||||
def funnels_delete(funnel_name: str):
|
||||
"""Delete a funnel."""
|
||||
resp = _request("delete", f"/v1/funnels/{funnel_name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Funnel '{funnel_name}' deleted")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -146,12 +240,25 @@ def web():
|
||||
@web.command(name="list")
|
||||
def web_list():
|
||||
"""List tracked websites."""
|
||||
resp = _request("get", "/v1/web/sites")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Tracked Websites", box=box.ROUNDED)
|
||||
table.add_column("Domain", style="cyan")
|
||||
table.add_column("Visitors", style="white")
|
||||
table.add_column("Pageviews", style="white")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for site in data.get("sites", []):
|
||||
st = site.get("status", "active")
|
||||
st_style = "green" if st == "active" else "yellow"
|
||||
table.add_row(
|
||||
site.get("domain", ""),
|
||||
str(site.get("visitors", 0)),
|
||||
str(site.get("pageviews", 0)),
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -159,12 +266,17 @@ def web_list():
|
||||
@click.argument("domain")
|
||||
def web_add(domain: str):
|
||||
"""Add a website to track."""
|
||||
resp = _request("post", "/v1/web/sites", json={"domain": domain})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Website '{domain}' added")
|
||||
console.print()
|
||||
|
||||
script_id = data.get("site_id", domain)
|
||||
console.print("[dim]Add this script to your website:[/dim]")
|
||||
console.print(
|
||||
Panel(
|
||||
f'<script defer src="https://analytics.hanzo.ai/script.js" data-website-id="{domain}"></script>',
|
||||
f'<script defer src="https://analytics.hanzo.ai/script.js" data-website-id="{script_id}"></script>',
|
||||
border_style="dim",
|
||||
)
|
||||
)
|
||||
@@ -175,18 +287,39 @@ def web_add(domain: str):
|
||||
@click.option("--period", "-p", default="7d", help="Time period (e.g., 7d, 30d)")
|
||||
def web_stats(domain: str, period: str):
|
||||
"""View website statistics."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Domain:[/cyan] {domain}\n"
|
||||
f"[cyan]Period:[/cyan] {period}\n"
|
||||
f"[cyan]Visitors:[/cyan] 12,456\n"
|
||||
f"[cyan]Pageviews:[/cyan] 45,678\n"
|
||||
f"[cyan]Bounce Rate:[/cyan] 42%\n"
|
||||
f"[cyan]Avg Duration:[/cyan] 2m 34s",
|
||||
title="Website Stats",
|
||||
border_style="cyan",
|
||||
)
|
||||
resp = _request("get", f"/v1/web/sites/{domain}/stats", params={"period": period})
|
||||
data = check_response(resp)
|
||||
|
||||
info = (
|
||||
f"[cyan]Domain:[/cyan] {data.get('domain', domain)}\n"
|
||||
f"[cyan]Period:[/cyan] {period}\n"
|
||||
f"[cyan]Visitors:[/cyan] {data.get('visitors', 0):,}\n"
|
||||
f"[cyan]Pageviews:[/cyan] {data.get('pageviews', 0):,}\n"
|
||||
f"[cyan]Bounce Rate:[/cyan] {data.get('bounce_rate', 0):.0f}%\n"
|
||||
f"[cyan]Avg Duration:[/cyan] {data.get('avg_duration', 'N/A')}"
|
||||
)
|
||||
console.print(Panel(info, title="Website Stats", border_style="cyan"))
|
||||
|
||||
pages = data.get("top_pages", [])
|
||||
if pages:
|
||||
table = Table(title="Top Pages", box=box.ROUNDED)
|
||||
table.add_column("Page", style="cyan")
|
||||
table.add_column("Views", style="white")
|
||||
table.add_column("Visitors", style="dim")
|
||||
|
||||
for p in pages[:10]:
|
||||
table.add_row(p.get("path", ""), str(p.get("views", 0)), str(p.get("visitors", 0)))
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@web.command(name="remove")
|
||||
@click.argument("domain")
|
||||
def web_remove(domain: str):
|
||||
"""Remove a tracked website."""
|
||||
resp = _request("delete", f"/v1/web/sites/{domain}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Website '{domain}' removed")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -203,12 +336,25 @@ def flags():
|
||||
@flags.command(name="list")
|
||||
def flags_list():
|
||||
"""List all feature flags."""
|
||||
resp = _request("get", "/v1/flags")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Feature Flags", box=box.ROUNDED)
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Rollout", style="white")
|
||||
table.add_column("Updated", style="dim")
|
||||
|
||||
for f in data.get("flags", []):
|
||||
enabled = f.get("enabled", False)
|
||||
status_str = "[green]enabled[/green]" if enabled else "[dim]disabled[/dim]"
|
||||
table.add_row(
|
||||
f.get("key", ""),
|
||||
status_str,
|
||||
f"{f.get('rollout', 0)}%",
|
||||
f.get("updated_at", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -218,6 +364,11 @@ def flags_list():
|
||||
@click.option("--rollout", "-r", default=0, help="Rollout percentage")
|
||||
def flags_create(key: str, description: str, rollout: int):
|
||||
"""Create a feature flag."""
|
||||
payload: dict = {"key": key, "rollout": rollout}
|
||||
if description:
|
||||
payload["description"] = description
|
||||
resp = _request("post", "/v1/flags", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Feature flag '{key}' created")
|
||||
|
||||
|
||||
@@ -226,6 +377,8 @@ def flags_create(key: str, description: str, rollout: int):
|
||||
@click.option("--rollout", "-r", default=100, help="Rollout percentage")
|
||||
def flags_enable(flag_key: str, rollout: int):
|
||||
"""Enable a feature flag."""
|
||||
resp = _request("patch", f"/v1/flags/{flag_key}", json={"enabled": True, "rollout": rollout})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flag '{flag_key}' enabled at {rollout}%")
|
||||
|
||||
|
||||
@@ -233,6 +386,8 @@ def flags_enable(flag_key: str, rollout: int):
|
||||
@click.argument("flag_key")
|
||||
def flags_disable(flag_key: str):
|
||||
"""Disable a feature flag."""
|
||||
resp = _request("patch", f"/v1/flags/{flag_key}", json={"enabled": False})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flag '{flag_key}' disabled")
|
||||
|
||||
|
||||
@@ -240,6 +395,8 @@ def flags_disable(flag_key: str):
|
||||
@click.argument("flag_key")
|
||||
def flags_delete(flag_key: str):
|
||||
"""Delete a feature flag."""
|
||||
resp = _request("delete", f"/v1/flags/{flag_key}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Flag '{flag_key}' deleted")
|
||||
|
||||
|
||||
@@ -258,6 +415,12 @@ def tests():
|
||||
@click.option("--status", type=click.Choice(["running", "completed", "draft", "all"]), default="all")
|
||||
def tests_list(status: str):
|
||||
"""List A/B tests."""
|
||||
params: dict = {}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
resp = _request("get", "/v1/tests", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="A/B Tests", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Status", style="green")
|
||||
@@ -265,6 +428,18 @@ def tests_list(status: str):
|
||||
table.add_column("Traffic", style="dim")
|
||||
table.add_column("Winner", style="yellow")
|
||||
|
||||
for t in data.get("tests", []):
|
||||
st = t.get("status", "draft")
|
||||
st_style = {"running": "green", "completed": "cyan", "draft": "dim"}.get(st, "white")
|
||||
winner = t.get("winner", "-")
|
||||
table.add_row(
|
||||
t.get("name", ""),
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
str(t.get("variant_count", 0)),
|
||||
f"{t.get('traffic_percent', 0)}%",
|
||||
winner,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -272,15 +447,26 @@ def tests_list(status: str):
|
||||
@click.option("--name", "-n", prompt=True, help="Test name")
|
||||
@click.option("--variants", "-v", default="control,treatment", help="Comma-separated variants")
|
||||
@click.option("--metric", "-m", required=True, help="Primary metric")
|
||||
def tests_create(name: str, variants: str, metric: str):
|
||||
@click.option("--traffic", "-t", default=100, help="Traffic percentage")
|
||||
def tests_create(name: str, variants: str, metric: str, traffic: int):
|
||||
"""Create an A/B test."""
|
||||
console.print(f"[green]✓[/green] A/B test '{name}' created")
|
||||
variant_list = [v.strip() for v in variants.split(",")]
|
||||
resp = _request("post", "/v1/tests", json={
|
||||
"name": name,
|
||||
"variants": variant_list,
|
||||
"primary_metric": metric,
|
||||
"traffic_percent": traffic,
|
||||
})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] A/B test '{name}' created with {len(variant_list)} variants")
|
||||
|
||||
|
||||
@tests.command(name="start")
|
||||
@click.argument("test_name")
|
||||
def tests_start(test_name: str):
|
||||
"""Start an A/B test."""
|
||||
resp = _request("post", f"/v1/tests/{test_name}/start")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] A/B test '{test_name}' started")
|
||||
|
||||
|
||||
@@ -288,26 +474,61 @@ def tests_start(test_name: str):
|
||||
@click.argument("test_name")
|
||||
def tests_stop(test_name: str):
|
||||
"""Stop an A/B test."""
|
||||
resp = _request("post", f"/v1/tests/{test_name}/stop")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] A/B test '{test_name}' stopped")
|
||||
|
||||
|
||||
@tests.command(name="results")
|
||||
@click.argument("test_name")
|
||||
def tests_results(test_name: str):
|
||||
@click.option("--format", "-f", "fmt", type=click.Choice(["table", "json"]), default="table")
|
||||
def tests_results(test_name: str, fmt: str):
|
||||
"""View A/B test results."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Test:[/cyan] {test_name}\n"
|
||||
f"[cyan]Status:[/cyan] Running\n"
|
||||
f"[cyan]Participants:[/cyan] 5,234\n"
|
||||
f"[cyan]Control conversion:[/cyan] 12.3%\n"
|
||||
f"[cyan]Treatment conversion:[/cyan] 14.7%\n"
|
||||
f"[cyan]Lift:[/cyan] +19.5%\n"
|
||||
f"[cyan]Confidence:[/cyan] 94%",
|
||||
title="Test Results",
|
||||
border_style="cyan",
|
||||
)
|
||||
resp = _request("get", f"/v1/tests/{test_name}/results")
|
||||
data = check_response(resp)
|
||||
|
||||
if fmt == "json":
|
||||
console.print(json.dumps(data, indent=2))
|
||||
return
|
||||
|
||||
info = (
|
||||
f"[cyan]Test:[/cyan] {data.get('name', test_name)}\n"
|
||||
f"[cyan]Status:[/cyan] {data.get('status', 'unknown')}\n"
|
||||
f"[cyan]Participants:[/cyan] {data.get('participant_count', 0):,}\n"
|
||||
f"[cyan]Confidence:[/cyan] {data.get('confidence', 0):.0f}%"
|
||||
)
|
||||
console.print(Panel(info, title="Test Results", border_style="cyan"))
|
||||
|
||||
variants = data.get("variants", [])
|
||||
if variants:
|
||||
table = Table(title="Variant Results", box=box.ROUNDED)
|
||||
table.add_column("Variant", style="cyan")
|
||||
table.add_column("Users", style="white")
|
||||
table.add_column("Conversions", style="green")
|
||||
table.add_column("Rate", style="yellow")
|
||||
table.add_column("Lift", style="dim")
|
||||
|
||||
for v in variants:
|
||||
is_winner = v.get("is_winner", False)
|
||||
name_str = f"[bold]{v.get('name', '')}[/bold] *" if is_winner else v.get("name", "")
|
||||
table.add_row(
|
||||
name_str,
|
||||
str(v.get("users", 0)),
|
||||
str(v.get("conversions", 0)),
|
||||
f"{v.get('conversion_rate', 0):.1f}%",
|
||||
f"{v.get('lift', 0):+.1f}%" if v.get("lift") else "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@tests.command(name="delete")
|
||||
@click.argument("test_name")
|
||||
def tests_delete(test_name: str):
|
||||
"""Delete an A/B test."""
|
||||
resp = _request("delete", f"/v1/tests/{test_name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] A/B test '{test_name}' deleted")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -325,6 +546,12 @@ def campaigns():
|
||||
@click.option("--status", type=click.Choice(["active", "draft", "completed", "all"]), default="all")
|
||||
def campaigns_list(status: str):
|
||||
"""List campaigns."""
|
||||
params: dict = {}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
resp = _request("get", "/v1/campaigns", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Campaigns", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Type", style="white")
|
||||
@@ -332,15 +559,37 @@ def campaigns_list(status: str):
|
||||
table.add_column("Sent", style="dim")
|
||||
table.add_column("Opens", style="dim")
|
||||
|
||||
for c in data.get("campaigns", []):
|
||||
st = c.get("status", "draft")
|
||||
st_style = {"active": "green", "completed": "cyan", "draft": "dim"}.get(st, "white")
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
c.get("type", ""),
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
str(c.get("sent_count", 0)),
|
||||
str(c.get("open_count", 0)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@campaigns.command(name="create")
|
||||
@click.option("--name", "-n", prompt=True, help="Campaign name")
|
||||
@click.option("--type", "-t", type=click.Choice(["email", "push", "sms", "in-app"]), default="email")
|
||||
@click.option("--type", "-t", "campaign_type", type=click.Choice(["email", "push", "sms", "in-app"]), default="email")
|
||||
@click.option("--segment", "-s", help="Target segment")
|
||||
def campaigns_create(name: str, type: str, segment: str):
|
||||
@click.option("--subject", help="Email subject")
|
||||
@click.option("--body", "-b", help="Message body")
|
||||
def campaigns_create(name: str, campaign_type: str, segment: str, subject: str, body: str):
|
||||
"""Create a campaign."""
|
||||
payload: dict = {"name": name, "type": campaign_type}
|
||||
if segment:
|
||||
payload["segment"] = segment
|
||||
if subject:
|
||||
payload["subject"] = subject
|
||||
if body:
|
||||
payload["body"] = body
|
||||
resp = _request("post", "/v1/campaigns", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Campaign '{name}' created")
|
||||
|
||||
|
||||
@@ -349,6 +598,11 @@ def campaigns_create(name: str, type: str, segment: str):
|
||||
@click.option("--schedule", help="Schedule time (ISO format)")
|
||||
def campaigns_send(campaign_name: str, schedule: str):
|
||||
"""Send or schedule a campaign."""
|
||||
payload: dict = {}
|
||||
if schedule:
|
||||
payload["scheduled_at"] = schedule
|
||||
resp = _request("post", f"/v1/campaigns/{campaign_name}/send", json=payload)
|
||||
check_response(resp)
|
||||
if schedule:
|
||||
console.print(f"[green]✓[/green] Campaign '{campaign_name}' scheduled for {schedule}")
|
||||
else:
|
||||
@@ -359,15 +613,35 @@ def campaigns_send(campaign_name: str, schedule: str):
|
||||
@click.argument("campaign_name")
|
||||
def campaigns_stats(campaign_name: str):
|
||||
"""View campaign statistics."""
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Campaign:[/cyan] {campaign_name}\n"
|
||||
f"[cyan]Sent:[/cyan] 10,234\n"
|
||||
f"[cyan]Delivered:[/cyan] 9,876 (96.5%)\n"
|
||||
f"[cyan]Opens:[/cyan] 2,345 (23.7%)\n"
|
||||
f"[cyan]Clicks:[/cyan] 567 (5.7%)\n"
|
||||
f"[cyan]Conversions:[/cyan] 89 (0.9%)",
|
||||
title="Campaign Stats",
|
||||
border_style="cyan",
|
||||
)
|
||||
resp = _request("get", f"/v1/campaigns/{campaign_name}/stats")
|
||||
data = check_response(resp)
|
||||
|
||||
sent = data.get("sent", 0)
|
||||
delivered = data.get("delivered", 0)
|
||||
opens = data.get("opens", 0)
|
||||
clicks = data.get("clicks", 0)
|
||||
conversions = data.get("conversions", 0)
|
||||
|
||||
delivery_rate = (delivered / sent * 100) if sent > 0 else 0
|
||||
open_rate = (opens / delivered * 100) if delivered > 0 else 0
|
||||
click_rate = (clicks / delivered * 100) if delivered > 0 else 0
|
||||
conv_rate = (conversions / delivered * 100) if delivered > 0 else 0
|
||||
|
||||
info = (
|
||||
f"[cyan]Campaign:[/cyan] {data.get('name', campaign_name)}\n"
|
||||
f"[cyan]Sent:[/cyan] {sent:,}\n"
|
||||
f"[cyan]Delivered:[/cyan] {delivered:,} ({delivery_rate:.1f}%)\n"
|
||||
f"[cyan]Opens:[/cyan] {opens:,} ({open_rate:.1f}%)\n"
|
||||
f"[cyan]Clicks:[/cyan] {clicks:,} ({click_rate:.1f}%)\n"
|
||||
f"[cyan]Conversions:[/cyan] {conversions:,} ({conv_rate:.1f}%)"
|
||||
)
|
||||
console.print(Panel(info, title="Campaign Stats", border_style="cyan"))
|
||||
|
||||
|
||||
@campaigns.command(name="delete")
|
||||
@click.argument("campaign_name")
|
||||
def campaigns_delete(campaign_name: str):
|
||||
"""Delete a campaign."""
|
||||
resp = _request("delete", f"/v1/campaigns/{campaign_name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Campaign '{campaign_name}' deleted")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Job scheduling, execution, and management.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
JOBS_URL = os.getenv("HANZO_JOBS_URL", "https://jobs.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(JOBS_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="jobs")
|
||||
@@ -48,15 +59,32 @@ def jobs_group():
|
||||
@click.option("--wait", "-w", is_flag=True, help="Wait for completion")
|
||||
def jobs_run(name: str, payload: str, queue: str, priority: int, delay: str, wait: bool):
|
||||
"""Run a background job."""
|
||||
job_id = "job_abc123"
|
||||
body = {"name": name, "queue": queue, "priority": priority}
|
||||
if payload:
|
||||
body["payload"] = json.loads(payload)
|
||||
if delay:
|
||||
body["delay"] = delay
|
||||
|
||||
resp = _request("post", "/v1/jobs", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
job_id = data.get("id", data.get("job_id", "-"))
|
||||
console.print(f"[green]✓[/green] Job '{name}' queued")
|
||||
console.print(f" ID: {job_id}")
|
||||
console.print(f" Queue: {queue}")
|
||||
console.print(f" Priority: {priority}")
|
||||
if delay:
|
||||
console.print(f" Delay: {delay}")
|
||||
|
||||
if wait:
|
||||
console.print("[dim]Waiting for completion...[/dim]")
|
||||
resp = _request("get", f"/v1/jobs/{job_id}/wait", timeout=300)
|
||||
result = check_response(resp)
|
||||
status = result.get("status", "unknown")
|
||||
style = "green" if status == "completed" else "red"
|
||||
console.print(f" Status: [{style}]{status}[/{style}]")
|
||||
if result.get("duration_ms"):
|
||||
console.print(f" Duration: {result['duration_ms']}ms")
|
||||
|
||||
|
||||
@jobs_group.command(name="list")
|
||||
@@ -65,6 +93,16 @@ def jobs_run(name: str, payload: str, queue: str, priority: int, delay: str, wai
|
||||
@click.option("--limit", "-n", default=20, help="Max results")
|
||||
def jobs_list(status: str, queue: str, limit: int):
|
||||
"""List jobs."""
|
||||
params = {"limit": limit}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
if queue:
|
||||
params["queue"] = queue
|
||||
|
||||
resp = _request("get", "/v1/jobs", params=params)
|
||||
data = check_response(resp)
|
||||
jobs = data.get("jobs", data.get("items", []))
|
||||
|
||||
table = Table(title="Jobs", box=box.ROUNDED)
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name", style="white")
|
||||
@@ -73,8 +111,27 @@ def jobs_list(status: str, queue: str, limit: int):
|
||||
table.add_column("Started", style="dim")
|
||||
table.add_column("Duration", style="dim")
|
||||
|
||||
for j in jobs:
|
||||
j_status = j.get("status", "unknown")
|
||||
status_style = {
|
||||
"pending": "yellow",
|
||||
"active": "cyan",
|
||||
"completed": "green",
|
||||
"failed": "red",
|
||||
}.get(j_status, "white")
|
||||
|
||||
table.add_row(
|
||||
str(j.get("id", ""))[:16],
|
||||
j.get("name", "-"),
|
||||
f"[{status_style}]{j_status}[/{status_style}]",
|
||||
j.get("queue", "-"),
|
||||
str(j.get("started_at", ""))[:19],
|
||||
f"{j.get('duration_ms', '-')}ms" if j.get("duration_ms") else "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No jobs found[/dim]")
|
||||
if not jobs:
|
||||
console.print("[dim]No jobs found[/dim]")
|
||||
|
||||
|
||||
@jobs_group.command(name="logs")
|
||||
@@ -83,8 +140,27 @@ def jobs_list(status: str, queue: str, limit: int):
|
||||
@click.option("--tail", "-n", default=100, help="Number of lines")
|
||||
def jobs_logs(job_id: str, follow: bool, tail: int):
|
||||
"""View job logs."""
|
||||
params = {"tail": tail}
|
||||
if follow:
|
||||
params["follow"] = "true"
|
||||
|
||||
resp = _request("get", f"/v1/jobs/{job_id}/logs", params=params)
|
||||
data = check_response(resp)
|
||||
lines = data.get("logs", data.get("lines", []))
|
||||
|
||||
console.print(f"[cyan]Logs for job {job_id}:[/cyan]")
|
||||
console.print("[dim]No logs available[/dim]")
|
||||
for line in lines:
|
||||
if isinstance(line, dict):
|
||||
ts = str(line.get("timestamp", ""))[:19]
|
||||
level = line.get("level", "info")
|
||||
msg = line.get("message", "")
|
||||
style = "red" if level == "error" else "yellow" if level == "warn" else "dim"
|
||||
console.print(f"[dim]{ts}[/dim] [{style}]{level}[/{style}] {msg}")
|
||||
else:
|
||||
console.print(str(line))
|
||||
|
||||
if not lines:
|
||||
console.print("[dim]No logs available[/dim]")
|
||||
|
||||
|
||||
@jobs_group.command(name="cancel")
|
||||
@@ -92,6 +168,12 @@ def jobs_logs(job_id: str, follow: bool, tail: int):
|
||||
@click.option("--force", "-f", is_flag=True, help="Force cancellation")
|
||||
def jobs_cancel(job_id: str, force: bool):
|
||||
"""Cancel a running job."""
|
||||
payload = {}
|
||||
if force:
|
||||
payload["force"] = True
|
||||
|
||||
resp = _request("post", f"/v1/jobs/{job_id}/cancel", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Job '{job_id}' cancelled")
|
||||
|
||||
|
||||
@@ -99,26 +181,45 @@ def jobs_cancel(job_id: str, force: bool):
|
||||
@click.argument("job_id")
|
||||
def jobs_retry(job_id: str):
|
||||
"""Retry a failed job."""
|
||||
resp = _request("post", f"/v1/jobs/{job_id}/retry")
|
||||
data = check_response(resp)
|
||||
new_id = data.get("id", data.get("job_id", "-"))
|
||||
console.print(f"[green]✓[/green] Job '{job_id}' requeued")
|
||||
console.print(f" New ID: {new_id}")
|
||||
|
||||
|
||||
@jobs_group.command(name="describe")
|
||||
@click.argument("job_id")
|
||||
def jobs_describe(job_id: str):
|
||||
"""Show job details."""
|
||||
resp = _request("get", f"/v1/jobs/{job_id}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "unknown")
|
||||
status_style = {
|
||||
"pending": "yellow",
|
||||
"active": "cyan",
|
||||
"completed": "green",
|
||||
"failed": "red",
|
||||
}.get(status, "white")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]ID:[/cyan] {job_id}\n"
|
||||
f"[cyan]Name:[/cyan] process-webhook\n"
|
||||
f"[cyan]Status:[/cyan] completed\n"
|
||||
f"[cyan]Queue:[/cyan] default\n"
|
||||
f"[cyan]Started:[/cyan] 2024-01-15 10:30:00\n"
|
||||
f"[cyan]Duration:[/cyan] 1.2s\n"
|
||||
f"[cyan]Attempts:[/cyan] 1/3",
|
||||
f"[cyan]ID:[/cyan] {data.get('id', job_id)}\n"
|
||||
f"[cyan]Name:[/cyan] {data.get('name', '-')}\n"
|
||||
f"[cyan]Status:[/cyan] [{status_style}]{status}[/{status_style}]\n"
|
||||
f"[cyan]Queue:[/cyan] {data.get('queue', '-')}\n"
|
||||
f"[cyan]Priority:[/cyan] {data.get('priority', '-')}\n"
|
||||
f"[cyan]Attempts:[/cyan] {data.get('attempts', 0)}/{data.get('max_attempts', 3)}\n"
|
||||
f"[cyan]Started:[/cyan] {str(data.get('started_at', '-'))[:19]}\n"
|
||||
f"[cyan]Duration:[/cyan] {data.get('duration_ms', '-')}ms\n"
|
||||
f"[cyan]Payload:[/cyan] {json.dumps(data.get('payload', {}), indent=2, default=str)}",
|
||||
title="Job Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
if data.get("error"):
|
||||
console.print(f"\n[red]Error:[/red] {data['error']}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -136,6 +237,14 @@ def cron():
|
||||
@click.option("--status", "-s", type=click.Choice(["active", "paused", "all"]), default="all")
|
||||
def cron_list(status: str):
|
||||
"""List cron schedules."""
|
||||
params = {}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
|
||||
resp = _request("get", "/v1/cron", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("schedules", data.get("items", []))
|
||||
|
||||
table = Table(title="Cron Schedules", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Schedule", style="white")
|
||||
@@ -143,8 +252,20 @@ def cron_list(status: str):
|
||||
table.add_column("Last Run", style="dim")
|
||||
table.add_column("Next Run", style="yellow")
|
||||
|
||||
for c in items:
|
||||
c_status = c.get("status", "active")
|
||||
style = "green" if c_status == "active" else "yellow"
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
c.get("schedule", "-"),
|
||||
f"[{style}]{c_status}[/{style}]",
|
||||
str(c.get("last_run_at", "-"))[:19],
|
||||
str(c.get("next_run_at", "-"))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No cron schedules found. Create one with 'hanzo jobs cron create'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No cron schedules found. Create one with 'hanzo jobs cron create'[/dim]")
|
||||
|
||||
|
||||
@cron.command(name="create")
|
||||
@@ -155,16 +276,27 @@ def cron_list(status: str):
|
||||
@click.option("--timezone", "-tz", default="UTC", help="Timezone")
|
||||
def cron_create(name: str, schedule: str, job: str, payload: str, timezone: str):
|
||||
"""Create a cron schedule."""
|
||||
body = {"name": name, "schedule": schedule, "job": job, "timezone": timezone}
|
||||
if payload:
|
||||
body["payload"] = json.loads(payload)
|
||||
|
||||
resp = _request("post", "/v1/cron", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Cron schedule '{name}' created")
|
||||
console.print(f" Schedule: {schedule}")
|
||||
console.print(f" Job: {job}")
|
||||
console.print(f" Timezone: {timezone}")
|
||||
if data.get("next_run_at"):
|
||||
console.print(f" Next run: {data['next_run_at']}")
|
||||
|
||||
|
||||
@cron.command(name="delete")
|
||||
@click.argument("name")
|
||||
def cron_delete(name: str):
|
||||
"""Delete a cron schedule."""
|
||||
resp = _request("delete", f"/v1/cron/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Cron schedule '{name}' deleted")
|
||||
|
||||
|
||||
@@ -172,6 +304,8 @@ def cron_delete(name: str):
|
||||
@click.argument("name")
|
||||
def cron_pause(name: str):
|
||||
"""Pause a cron schedule."""
|
||||
resp = _request("post", f"/v1/cron/{name}/pause")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Cron schedule '{name}' paused")
|
||||
|
||||
|
||||
@@ -179,6 +313,8 @@ def cron_pause(name: str):
|
||||
@click.argument("name")
|
||||
def cron_resume(name: str):
|
||||
"""Resume a paused cron schedule."""
|
||||
resp = _request("post", f"/v1/cron/{name}/resume")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Cron schedule '{name}' resumed")
|
||||
|
||||
|
||||
@@ -186,5 +322,7 @@ def cron_resume(name: str):
|
||||
@click.argument("name")
|
||||
def cron_run_now(name: str):
|
||||
"""Trigger a cron job immediately."""
|
||||
resp = _request("post", f"/v1/cron/{name}/trigger")
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Cron job '{name}' triggered")
|
||||
console.print(" Job ID: job_xyz789")
|
||||
console.print(f" Job ID: {data.get('job_id', data.get('id', '-'))}")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Redis-compatible key-value storage with global replication.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
KV_URL = os.getenv("HANZO_KV_URL", "https://kv.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(KV_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="kv")
|
||||
@@ -48,7 +59,15 @@ def kv_group():
|
||||
@click.option("--eviction", type=click.Choice(["lru", "lfu", "ttl", "none"]), default="lru")
|
||||
def kv_create(name: str, region: tuple, max_size: str, eviction: str):
|
||||
"""Create a KV store."""
|
||||
payload = {"name": name, "max_size": max_size, "eviction_policy": eviction}
|
||||
if region:
|
||||
payload["regions"] = list(region)
|
||||
|
||||
resp = _request("post", "/v1/stores", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] KV store '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Max size: {max_size}")
|
||||
console.print(f" Eviction: {eviction}")
|
||||
if region:
|
||||
@@ -58,14 +77,28 @@ def kv_create(name: str, region: tuple, max_size: str, eviction: str):
|
||||
@kv_group.command(name="list")
|
||||
def kv_list():
|
||||
"""List KV stores."""
|
||||
resp = _request("get", "/v1/stores")
|
||||
data = check_response(resp)
|
||||
stores = data.get("stores", data.get("items", []))
|
||||
|
||||
table = Table(title="KV Stores", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Region", style="white")
|
||||
table.add_column("Keys", style="green")
|
||||
table.add_column("Size", style="yellow")
|
||||
|
||||
for s in stores:
|
||||
regions = s.get("regions", [])
|
||||
table.add_row(
|
||||
s.get("name", ""),
|
||||
", ".join(regions) if regions else s.get("region", "-"),
|
||||
str(s.get("key_count", 0)),
|
||||
s.get("size", "0 B"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No KV stores found. Create one with 'hanzo kv create'[/dim]")
|
||||
if not stores:
|
||||
console.print("[dim]No KV stores found. Create one with 'hanzo kv create'[/dim]")
|
||||
|
||||
|
||||
@kv_group.command(name="delete")
|
||||
@@ -78,6 +111,9 @@ def kv_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete KV store '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/stores/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] KV store '{name}' deleted")
|
||||
|
||||
|
||||
@@ -91,7 +127,13 @@ def kv_delete(name: str, force: bool):
|
||||
@click.option("--store", "-s", default="default", help="KV store name")
|
||||
def kv_get(key: str, store: str):
|
||||
"""Get a value by key."""
|
||||
console.print(f"[dim]Key '{key}' not found in store '{store}'[/dim]")
|
||||
resp = _request("get", f"/v1/stores/{store}/keys/{key}")
|
||||
if resp.status_code == 404:
|
||||
console.print(f"[dim]Key '{key}' not found in store '{store}'[/dim]")
|
||||
return
|
||||
data = check_response(resp)
|
||||
value = data.get("value", "")
|
||||
console.print(f"[cyan]{key}[/cyan] = {value}")
|
||||
|
||||
|
||||
@kv_group.command(name="set")
|
||||
@@ -102,9 +144,21 @@ def kv_get(key: str, store: str):
|
||||
@click.option("--nx", is_flag=True, help="Only set if not exists")
|
||||
def kv_set(key: str, value: str, store: str, ttl: str, nx: bool):
|
||||
"""Set a key-value pair."""
|
||||
console.print(f"[green]✓[/green] Set '{key}' in store '{store}'")
|
||||
payload = {"key": key, "value": value}
|
||||
if ttl:
|
||||
console.print(f" TTL: {ttl}")
|
||||
payload["ttl"] = ttl
|
||||
if nx:
|
||||
payload["nx"] = True
|
||||
|
||||
resp = _request("put", f"/v1/stores/{store}/keys/{key}", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
if nx and not data.get("set", True):
|
||||
console.print(f"[yellow]Key '{key}' already exists (NX mode)[/yellow]")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Set '{key}' in store '{store}'")
|
||||
if ttl:
|
||||
console.print(f" TTL: {ttl}")
|
||||
|
||||
|
||||
@kv_group.command(name="del")
|
||||
@@ -112,7 +166,9 @@ def kv_set(key: str, value: str, store: str, ttl: str, nx: bool):
|
||||
@click.option("--store", "-s", default="default", help="KV store name")
|
||||
def kv_del(keys: tuple, store: str):
|
||||
"""Delete one or more keys."""
|
||||
console.print(f"[green]✓[/green] Deleted {len(keys)} key(s)")
|
||||
resp = _request("post", f"/v1/stores/{store}/delete", json={"keys": list(keys)})
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Deleted {data.get('deleted', len(keys))} key(s)")
|
||||
|
||||
|
||||
@kv_group.command(name="keys")
|
||||
@@ -121,8 +177,21 @@ def kv_del(keys: tuple, store: str):
|
||||
@click.option("--limit", "-n", default=100, help="Max keys")
|
||||
def kv_keys(store: str, pattern: str, limit: int):
|
||||
"""List keys matching pattern."""
|
||||
resp = _request("get", f"/v1/stores/{store}/keys", params={"pattern": pattern, "limit": limit})
|
||||
data = check_response(resp)
|
||||
keys = data.get("keys", [])
|
||||
|
||||
console.print(f"[cyan]Keys matching '{pattern}':[/cyan]")
|
||||
console.print("[dim]No keys found[/dim]")
|
||||
for k in keys:
|
||||
if isinstance(k, dict):
|
||||
console.print(f" {k.get('key', k.get('name', ''))}")
|
||||
else:
|
||||
console.print(f" {k}")
|
||||
|
||||
if not keys:
|
||||
console.print("[dim]No keys found[/dim]")
|
||||
else:
|
||||
console.print(f"\n[dim]{len(keys)} key(s)[/dim]")
|
||||
|
||||
|
||||
@kv_group.command(name="mget")
|
||||
@@ -130,9 +199,20 @@ def kv_keys(store: str, pattern: str, limit: int):
|
||||
@click.option("--store", "-s", default="default")
|
||||
def kv_mget(keys: tuple, store: str):
|
||||
"""Get multiple keys."""
|
||||
resp = _request("post", f"/v1/stores/{store}/mget", json={"keys": list(keys)})
|
||||
data = check_response(resp)
|
||||
results = data.get("results", data.get("values", []))
|
||||
|
||||
table = Table(box=box.SIMPLE)
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Value", style="white")
|
||||
|
||||
for r in results:
|
||||
if isinstance(r, dict):
|
||||
table.add_row(r.get("key", ""), str(r.get("value", "(nil)")))
|
||||
else:
|
||||
table.add_row("-", str(r))
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -141,8 +221,16 @@ def kv_mget(keys: tuple, store: str):
|
||||
@click.option("--store", "-s", default="default")
|
||||
def kv_mset(pairs: tuple, store: str):
|
||||
"""Set multiple key-value pairs (key1 val1 key2 val2 ...)."""
|
||||
count = len(pairs) // 2
|
||||
console.print(f"[green]✓[/green] Set {count} key(s)")
|
||||
if len(pairs) % 2 != 0:
|
||||
raise click.ClickException("Pairs must be even: key1 val1 key2 val2 ...")
|
||||
|
||||
items = []
|
||||
for i in range(0, len(pairs), 2):
|
||||
items.append({"key": pairs[i], "value": pairs[i + 1]})
|
||||
|
||||
resp = _request("post", f"/v1/stores/{store}/mset", json={"items": items})
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Set {data.get('set', len(items))} key(s)")
|
||||
|
||||
|
||||
@kv_group.command(name="ttl")
|
||||
@@ -152,18 +240,36 @@ def kv_mset(pairs: tuple, store: str):
|
||||
def kv_ttl(key: str, store: str, set_ttl: str):
|
||||
"""Get or set TTL for a key."""
|
||||
if set_ttl:
|
||||
resp = _request("put", f"/v1/stores/{store}/keys/{key}/ttl", json={"ttl": set_ttl})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Set TTL for '{key}' to {set_ttl}")
|
||||
else:
|
||||
console.print(f"[dim]Key '{key}' has no TTL[/dim]")
|
||||
resp = _request("get", f"/v1/stores/{store}/keys/{key}/ttl")
|
||||
data = check_response(resp)
|
||||
ttl_val = data.get("ttl", -1)
|
||||
if ttl_val == -1:
|
||||
console.print(f"[dim]Key '{key}' has no TTL (persistent)[/dim]")
|
||||
elif ttl_val == -2:
|
||||
console.print(f"[dim]Key '{key}' does not exist[/dim]")
|
||||
else:
|
||||
console.print(f"[cyan]TTL for '{key}':[/cyan] {ttl_val}s")
|
||||
|
||||
|
||||
@kv_group.command(name="stats")
|
||||
@click.option("--store", "-s", default="default")
|
||||
def kv_stats(store: str):
|
||||
"""Show store statistics."""
|
||||
resp = _request("get", f"/v1/stores/{store}/stats")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Store:[/cyan] {store}\n[cyan]Keys:[/cyan] 0\n[cyan]Memory:[/cyan] 0 B\n[cyan]Hit rate:[/cyan] 0%",
|
||||
f"[cyan]Store:[/cyan] {data.get('name', store)}\n"
|
||||
f"[cyan]Keys:[/cyan] {data.get('key_count', 0):,}\n"
|
||||
f"[cyan]Memory:[/cyan] {data.get('memory', '0 B')}\n"
|
||||
f"[cyan]Hit rate:[/cyan] {data.get('hit_rate', 0):.1f}%\n"
|
||||
f"[cyan]Ops/sec:[/cyan] {data.get('ops_per_sec', 0):,}\n"
|
||||
f"[cyan]Eviction policy:[/cyan] {data.get('eviction_policy', '-')}",
|
||||
title="KV Statistics",
|
||||
border_style="cyan",
|
||||
)
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
End-to-end MLOps: notebooks, pipelines, training, serving, registry.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
ML_URL = os.getenv("HANZO_ML_URL", "https://ml.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(ML_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="ml")
|
||||
@@ -57,6 +68,10 @@ def notebooks():
|
||||
@notebooks.command(name="list")
|
||||
def notebooks_list():
|
||||
"""List all notebooks."""
|
||||
resp = _request("get", "/v1/notebooks")
|
||||
data = check_response(resp)
|
||||
items = data.get("notebooks", data.get("items", []))
|
||||
|
||||
table = Table(title="Notebooks", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Instance", style="white")
|
||||
@@ -64,8 +79,20 @@ def notebooks_list():
|
||||
table.add_column("GPU", style="yellow")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for n in items:
|
||||
n_status = n.get("status", "stopped")
|
||||
style = "green" if n_status == "running" else "yellow"
|
||||
table.add_row(
|
||||
n.get("name", ""),
|
||||
n.get("instance", "-"),
|
||||
f"[{style}]{n_status}[/{style}]",
|
||||
n.get("gpu", "None"),
|
||||
str(n.get("created_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No notebooks found. Create one with 'hanzo ml notebooks create'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No notebooks found. Create one with 'hanzo ml notebooks create'[/dim]")
|
||||
|
||||
|
||||
@notebooks.command(name="create")
|
||||
@@ -74,18 +101,25 @@ def notebooks_list():
|
||||
@click.option("--gpu", is_flag=True, help="Enable GPU")
|
||||
def notebooks_create(name: str, instance: str, gpu: bool):
|
||||
"""Create a new notebook instance."""
|
||||
body = {"name": name, "instance": instance, "gpu": gpu}
|
||||
|
||||
console.print(f"[cyan]Creating notebook '{name}'...[/cyan]")
|
||||
resp = _request("post", "/v1/notebooks", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Notebook '{name}' created")
|
||||
console.print(f" Instance: {instance}")
|
||||
console.print(f" GPU: {'Yes' if gpu else 'No'}")
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] Notebook '{name}' created")
|
||||
console.print(f"[dim]Access at: https://notebooks.hanzo.ai/{name}[/dim]")
|
||||
if data.get("url"):
|
||||
console.print(f" URL: {data['url']}")
|
||||
|
||||
|
||||
@notebooks.command(name="start")
|
||||
@click.argument("name")
|
||||
def notebooks_start(name: str):
|
||||
"""Start a notebook."""
|
||||
resp = _request("post", f"/v1/notebooks/{name}/start")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Notebook '{name}' started")
|
||||
|
||||
|
||||
@@ -93,6 +127,8 @@ def notebooks_start(name: str):
|
||||
@click.argument("name")
|
||||
def notebooks_stop(name: str):
|
||||
"""Stop a notebook."""
|
||||
resp = _request("post", f"/v1/notebooks/{name}/stop")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Notebook '{name}' stopped")
|
||||
|
||||
|
||||
@@ -104,6 +140,9 @@ def notebooks_delete(name: str):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete notebook '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/notebooks/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Notebook '{name}' deleted")
|
||||
|
||||
|
||||
@@ -122,6 +161,14 @@ def training():
|
||||
@click.option("--status", type=click.Choice(["running", "completed", "failed", "all"]), default="all")
|
||||
def training_list(status: str):
|
||||
"""List training jobs."""
|
||||
params = {}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
|
||||
resp = _request("get", "/v1/training", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("jobs", data.get("items", []))
|
||||
|
||||
table = Table(title="Training Jobs", box=box.ROUNDED)
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name", style="white")
|
||||
@@ -130,7 +177,21 @@ def training_list(status: str):
|
||||
table.add_column("Duration", style="dim")
|
||||
table.add_column("GPU", style="yellow")
|
||||
|
||||
for j in items:
|
||||
j_status = j.get("status", "unknown")
|
||||
status_style = {"running": "cyan", "completed": "green", "failed": "red"}.get(j_status, "white")
|
||||
table.add_row(
|
||||
str(j.get("id", ""))[:16],
|
||||
j.get("name", "-"),
|
||||
j.get("framework", "-"),
|
||||
f"[{status_style}]{j_status}[/{status_style}]",
|
||||
f"{j.get('duration_ms', '-')}ms" if j.get("duration_ms") else "-",
|
||||
j.get("gpu", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No training jobs found[/dim]")
|
||||
|
||||
|
||||
@training.command(name="create")
|
||||
@@ -141,12 +202,17 @@ def training_list(status: str):
|
||||
@click.option("--instance", "-i", default="gpu-a10g", help="Instance type")
|
||||
def training_create(name: str, framework: str, script: str, gpu: str, instance: str):
|
||||
"""Create a training job."""
|
||||
body = {"name": name, "framework": framework, "script": script, "gpu": gpu, "instance": instance}
|
||||
|
||||
console.print(f"[cyan]Creating training job '{name}'...[/cyan]")
|
||||
resp = _request("post", "/v1/training", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Training job '{name}' started")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Framework: {framework}")
|
||||
console.print(f" Script: {script}")
|
||||
console.print(f" Instance: {instance} ({gpu} GPUs)")
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] Training job '{name}' started")
|
||||
|
||||
|
||||
@training.command(name="logs")
|
||||
@@ -154,13 +220,31 @@ def training_create(name: str, framework: str, script: str, gpu: str, instance:
|
||||
@click.option("--follow", "-f", is_flag=True, help="Follow logs")
|
||||
def training_logs(job_id: str, follow: bool):
|
||||
"""View training job logs."""
|
||||
params = {}
|
||||
if follow:
|
||||
params["follow"] = "true"
|
||||
|
||||
resp = _request("get", f"/v1/training/{job_id}/logs", params=params)
|
||||
data = check_response(resp)
|
||||
lines = data.get("logs", data.get("lines", []))
|
||||
|
||||
console.print(f"[cyan]Logs for job {job_id}:[/cyan]")
|
||||
for line in lines:
|
||||
if isinstance(line, dict):
|
||||
console.print(f"[dim]{str(line.get('timestamp', ''))[:19]}[/dim] {line.get('message', '')}")
|
||||
else:
|
||||
console.print(str(line))
|
||||
|
||||
if not lines:
|
||||
console.print("[dim]No logs available[/dim]")
|
||||
|
||||
|
||||
@training.command(name="stop")
|
||||
@click.argument("job_id")
|
||||
def training_stop(job_id: str):
|
||||
"""Stop a training job."""
|
||||
resp = _request("post", f"/v1/training/{job_id}/stop")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Training job '{job_id}' stopped")
|
||||
|
||||
|
||||
@@ -178,13 +262,29 @@ def pipelines():
|
||||
@pipelines.command(name="list")
|
||||
def pipelines_list():
|
||||
"""List all pipelines."""
|
||||
resp = _request("get", "/v1/pipelines")
|
||||
data = check_response(resp)
|
||||
items = data.get("pipelines", data.get("items", []))
|
||||
|
||||
table = Table(title="ML Pipelines", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Version", style="white")
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Last Run", style="dim")
|
||||
|
||||
for p in items:
|
||||
p_status = p.get("status", "idle")
|
||||
style = "green" if p_status == "active" else "dim"
|
||||
table.add_row(
|
||||
p.get("name", ""),
|
||||
str(p.get("version", "-")),
|
||||
f"[{style}]{p_status}[/{style}]",
|
||||
str(p.get("last_run_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No pipelines found[/dim]")
|
||||
|
||||
|
||||
@pipelines.command(name="create")
|
||||
@@ -192,7 +292,14 @@ def pipelines_list():
|
||||
@click.option("--file", "-f", required=True, help="Pipeline definition file")
|
||||
def pipelines_create(name: str, file: str):
|
||||
"""Create a pipeline from definition file."""
|
||||
with open(file) as f:
|
||||
definition = json.load(f)
|
||||
|
||||
resp = _request("post", "/v1/pipelines", json={"name": name, "definition": definition})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Pipeline '{name}' created from {file}")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
|
||||
|
||||
@pipelines.command(name="run")
|
||||
@@ -200,8 +307,15 @@ def pipelines_create(name: str, file: str):
|
||||
@click.option("--params", "-p", help="JSON parameters")
|
||||
def pipelines_run(pipeline_name: str, params: str):
|
||||
"""Run a pipeline."""
|
||||
body = {}
|
||||
if params:
|
||||
body["params"] = json.loads(params)
|
||||
|
||||
console.print(f"[cyan]Running pipeline '{pipeline_name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/pipelines/{pipeline_name}/run", json=body)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Pipeline run started")
|
||||
console.print(f" Run ID: {data.get('id', data.get('run_id', '-'))}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -218,6 +332,10 @@ def serving():
|
||||
@serving.command(name="list")
|
||||
def serving_list():
|
||||
"""List model deployments."""
|
||||
resp = _request("get", "/v1/serving")
|
||||
data = check_response(resp)
|
||||
items = data.get("deployments", data.get("items", []))
|
||||
|
||||
table = Table(title="Model Deployments", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Model", style="white")
|
||||
@@ -226,7 +344,21 @@ def serving_list():
|
||||
table.add_column("Replicas", style="dim")
|
||||
table.add_column("Endpoint", style="dim")
|
||||
|
||||
for d in items:
|
||||
d_status = d.get("status", "unknown")
|
||||
style = "green" if d_status == "ready" else "yellow"
|
||||
table.add_row(
|
||||
d.get("name", ""),
|
||||
d.get("model", "-"),
|
||||
str(d.get("version", "-")),
|
||||
f"[{style}]{d_status}[/{style}]",
|
||||
str(d.get("replicas", 0)),
|
||||
d.get("endpoint", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No deployments found[/dim]")
|
||||
|
||||
|
||||
@serving.command(name="deploy")
|
||||
@@ -236,13 +368,16 @@ def serving_list():
|
||||
@click.option("--gpu", is_flag=True, help="Enable GPU inference")
|
||||
def serving_deploy(name: str, model: str, replicas: int, gpu: bool):
|
||||
"""Deploy a model for inference."""
|
||||
body = {"name": name, "model": model, "replicas": replicas, "gpu": gpu}
|
||||
|
||||
console.print(f"[cyan]Deploying model '{name}'...[/cyan]")
|
||||
console.print(f" Model: {model}")
|
||||
resp = _request("post", "/v1/serving", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Model deployed")
|
||||
console.print(f" Endpoint: {data.get('endpoint', '-')}")
|
||||
console.print(f" Replicas: {replicas}")
|
||||
console.print(f" GPU: {'Yes' if gpu else 'No'}")
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] Model deployed")
|
||||
console.print(f"[dim]Endpoint: https://serving.hanzo.ai/{name}[/dim]")
|
||||
|
||||
|
||||
@serving.command(name="scale")
|
||||
@@ -250,6 +385,8 @@ def serving_deploy(name: str, model: str, replicas: int, gpu: bool):
|
||||
@click.option("--replicas", "-r", required=True, type=int, help="Target replicas")
|
||||
def serving_scale(name: str, replicas: int):
|
||||
"""Scale a deployment."""
|
||||
resp = _request("put", f"/v1/serving/{name}/scale", json={"replicas": replicas})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Deployment '{name}' scaled to {replicas} replicas")
|
||||
|
||||
|
||||
@@ -257,6 +394,8 @@ def serving_scale(name: str, replicas: int):
|
||||
@click.argument("name")
|
||||
def serving_delete(name: str):
|
||||
"""Delete a deployment."""
|
||||
resp = _request("delete", f"/v1/serving/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Deployment '{name}' deleted")
|
||||
|
||||
|
||||
@@ -275,6 +414,14 @@ def registry():
|
||||
@click.option("--model", "-m", help="Filter by model name")
|
||||
def registry_list(model: str):
|
||||
"""List registered models."""
|
||||
params = {}
|
||||
if model:
|
||||
params["model"] = model
|
||||
|
||||
resp = _request("get", "/v1/registry", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("models", data.get("items", []))
|
||||
|
||||
table = Table(title="Model Registry", box=box.ROUNDED)
|
||||
table.add_column("Model", style="cyan")
|
||||
table.add_column("Version", style="white")
|
||||
@@ -282,7 +429,20 @@ def registry_list(model: str):
|
||||
table.add_column("Framework", style="dim")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for m in items:
|
||||
stage = m.get("stage", "none")
|
||||
style = "green" if stage == "production" else "yellow" if stage == "staging" else "dim"
|
||||
table.add_row(
|
||||
m.get("name", ""),
|
||||
str(m.get("version", "-")),
|
||||
f"[{style}]{stage}[/{style}]",
|
||||
m.get("framework", "-"),
|
||||
str(m.get("created_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No models registered[/dim]")
|
||||
|
||||
|
||||
@registry.command(name="push")
|
||||
@@ -292,11 +452,17 @@ def registry_list(model: str):
|
||||
@click.option("--version", "-v", help="Version (auto-incremented if not specified)")
|
||||
def registry_push(name: str, path: str, framework: str, version: str):
|
||||
"""Push a model to the registry."""
|
||||
body = {"name": name, "path": path, "framework": framework}
|
||||
if version:
|
||||
body["version"] = version
|
||||
|
||||
console.print(f"[cyan]Pushing model '{name}'...[/cyan]")
|
||||
console.print(f" Path: {path}")
|
||||
console.print(f" Framework: {framework}")
|
||||
console.print()
|
||||
resp = _request("post", "/v1/registry", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Model '{name}' pushed to registry")
|
||||
console.print(f" Version: {data.get('version', '-')}")
|
||||
console.print(f" Framework: {framework}")
|
||||
|
||||
|
||||
@registry.command(name="pull")
|
||||
@@ -305,7 +471,9 @@ def registry_push(name: str, path: str, framework: str, version: str):
|
||||
def registry_pull(model_uri: str, output: str):
|
||||
"""Pull a model from the registry."""
|
||||
console.print(f"[cyan]Pulling model {model_uri}...[/cyan]")
|
||||
console.print(f"[green]✓[/green] Model downloaded to {output}")
|
||||
resp = _request("post", "/v1/registry/pull", json={"uri": model_uri, "output": output})
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Model downloaded to {data.get('path', output)}")
|
||||
|
||||
|
||||
@registry.command(name="promote")
|
||||
@@ -313,6 +481,8 @@ def registry_pull(model_uri: str, output: str):
|
||||
@click.option("--stage", "-s", type=click.Choice(["staging", "production"]), required=True)
|
||||
def registry_promote(model_uri: str, stage: str):
|
||||
"""Promote a model version to a stage."""
|
||||
resp = _request("post", "/v1/registry/promote", json={"uri": model_uri, "stage": stage})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Model promoted to {stage}")
|
||||
|
||||
|
||||
@@ -333,7 +503,15 @@ def experiments():
|
||||
@click.option("--namespace", "-n", default="default", help="Namespace")
|
||||
def experiments_create(name: str, description: str, namespace: str):
|
||||
"""Create an experiment."""
|
||||
body = {"name": name, "namespace": namespace}
|
||||
if description:
|
||||
body["description"] = description
|
||||
|
||||
resp = _request("post", "/v1/experiments", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Experiment '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
if description:
|
||||
console.print(f" Description: {description}")
|
||||
|
||||
@@ -342,14 +520,27 @@ def experiments_create(name: str, description: str, namespace: str):
|
||||
@click.option("--namespace", "-n", default="default", help="Namespace")
|
||||
def experiments_list(namespace: str):
|
||||
"""List experiments."""
|
||||
resp = _request("get", "/v1/experiments", params={"namespace": namespace})
|
||||
data = check_response(resp)
|
||||
items = data.get("experiments", data.get("items", []))
|
||||
|
||||
table = Table(title="Experiments", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Runs", style="green")
|
||||
table.add_column("Best Metric", style="yellow")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for e in items:
|
||||
table.add_row(
|
||||
e.get("name", ""),
|
||||
str(e.get("run_count", 0)),
|
||||
str(e.get("best_metric", "-")),
|
||||
str(e.get("created_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No experiments found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No experiments found[/dim]")
|
||||
|
||||
|
||||
@experiments.command(name="runs")
|
||||
@@ -357,25 +548,55 @@ def experiments_list(namespace: str):
|
||||
@click.option("--status", type=click.Choice(["running", "completed", "failed", "all"]), default="all")
|
||||
def experiments_runs(experiment: str, status: str):
|
||||
"""List runs in an experiment."""
|
||||
params = {}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
|
||||
resp = _request("get", f"/v1/experiments/{experiment}/runs", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("runs", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Runs in '{experiment}'", box=box.ROUNDED)
|
||||
table.add_column("Run ID", style="cyan")
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Metrics", style="yellow")
|
||||
table.add_column("Duration", style="dim")
|
||||
|
||||
for r in items:
|
||||
r_status = r.get("status", "unknown")
|
||||
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(r_status, "white")
|
||||
metrics = json.dumps(r.get("metrics", {}), default=str)[:40] if r.get("metrics") else "-"
|
||||
table.add_row(
|
||||
str(r.get("id", ""))[:16],
|
||||
f"[{style}]{r_status}[/{style}]",
|
||||
metrics,
|
||||
f"{r.get('duration_ms', '-')}ms" if r.get("duration_ms") else "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No runs found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No runs found[/dim]")
|
||||
|
||||
|
||||
@experiments.command(name="compare")
|
||||
@click.argument("run_ids", nargs=-1, required=True)
|
||||
def experiments_compare(run_ids: tuple):
|
||||
"""Compare experiment runs."""
|
||||
resp = _request("post", "/v1/experiments/compare", json={"run_ids": list(run_ids)})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[cyan]Comparing {len(run_ids)} runs...[/cyan]")
|
||||
table = Table(title="Run Comparison", box=box.ROUNDED)
|
||||
table.add_column("Metric", style="cyan")
|
||||
for run_id in run_ids:
|
||||
table.add_column(run_id[:8], style="white")
|
||||
|
||||
for metric in data.get("metrics", []):
|
||||
row = [metric.get("name", "")]
|
||||
for run_id in run_ids:
|
||||
row.append(str(metric.get("values", {}).get(run_id, "-")))
|
||||
table.add_row(*row)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -389,6 +610,9 @@ def experiments_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete experiment '{name}' and all runs?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/experiments/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Experiment '{name}' deleted")
|
||||
|
||||
|
||||
@@ -415,14 +639,8 @@ def tune():
|
||||
@click.option("--parallel-trials", "-p", default=2, help="Parallel trials")
|
||||
@click.option("--config", "-c", help="Tuning config file (YAML)")
|
||||
def tune_create(
|
||||
name: str,
|
||||
experiment: str,
|
||||
objective: str,
|
||||
goal: str,
|
||||
algorithm: str,
|
||||
max_trials: int,
|
||||
parallel_trials: int,
|
||||
config: str,
|
||||
name: str, experiment: str, objective: str, goal: str, algorithm: str,
|
||||
max_trials: int, parallel_trials: int, config: str,
|
||||
):
|
||||
"""Create a hyperparameter tuning job.
|
||||
|
||||
@@ -432,8 +650,21 @@ def tune_create(
|
||||
hanzo ml tune create grid-search -e exp -o accuracy -g maximize -a grid -m 100
|
||||
hanzo ml tune create custom -e exp -o f1 -c tuning.yaml
|
||||
"""
|
||||
body = {
|
||||
"name": name, "experiment": experiment, "objective": objective,
|
||||
"goal": goal, "algorithm": algorithm, "max_trials": max_trials,
|
||||
"parallel_trials": parallel_trials,
|
||||
}
|
||||
if config:
|
||||
with open(config) as f:
|
||||
body["config"] = json.load(f)
|
||||
|
||||
console.print(f"[cyan]Creating tuning job '{name}'...[/cyan]")
|
||||
resp = _request("post", "/v1/tune", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Tuning job created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Experiment: {experiment}")
|
||||
console.print(f" Objective: {objective} ({goal})")
|
||||
console.print(f" Algorithm: {algorithm}")
|
||||
@@ -444,6 +675,14 @@ def tune_create(
|
||||
@click.option("--experiment", "-e", help="Filter by experiment")
|
||||
def tune_list(experiment: str):
|
||||
"""List tuning jobs."""
|
||||
params = {}
|
||||
if experiment:
|
||||
params["experiment"] = experiment
|
||||
|
||||
resp = _request("get", "/v1/tune", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("jobs", data.get("items", []))
|
||||
|
||||
table = Table(title="Tuning Jobs", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Algorithm", style="white")
|
||||
@@ -451,8 +690,20 @@ def tune_list(experiment: str):
|
||||
table.add_column("Best", style="yellow")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for j in items:
|
||||
j_status = j.get("status", "unknown")
|
||||
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(j_status, "white")
|
||||
table.add_row(
|
||||
j.get("name", ""),
|
||||
j.get("algorithm", "-"),
|
||||
f"{j.get('completed_trials', 0)}/{j.get('max_trials', 0)}",
|
||||
str(j.get("best_metric", "-")),
|
||||
f"[{style}]{j_status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No tuning jobs found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No tuning jobs found[/dim]")
|
||||
|
||||
|
||||
@tune.command(name="trials")
|
||||
@@ -460,28 +711,57 @@ def tune_list(experiment: str):
|
||||
@click.option("--best", "-b", type=int, help="Show top N trials")
|
||||
def tune_trials(name: str, best: int):
|
||||
"""List trials in a tuning job."""
|
||||
params = {}
|
||||
if best:
|
||||
params["top"] = best
|
||||
|
||||
resp = _request("get", f"/v1/tune/{name}/trials", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("trials", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Trials for '{name}'", box=box.ROUNDED)
|
||||
table.add_column("Trial", style="cyan")
|
||||
table.add_column("Parameters", style="white")
|
||||
table.add_column("Metric", style="yellow")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for t in items:
|
||||
params_str = json.dumps(t.get("parameters", {}), default=str)[:40]
|
||||
table.add_row(
|
||||
str(t.get("id", "")),
|
||||
params_str,
|
||||
str(t.get("metric", "-")),
|
||||
t.get("status", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No trials found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No trials found[/dim]")
|
||||
|
||||
|
||||
@tune.command(name="best")
|
||||
@click.argument("name")
|
||||
def tune_best(name: str):
|
||||
"""Get best trial parameters."""
|
||||
resp = _request("get", f"/v1/tune/{name}/best")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[cyan]Best trial for '{name}':[/cyan]")
|
||||
console.print("[dim]No trials completed[/dim]")
|
||||
if data.get("parameters"):
|
||||
console.print(f" Metric: {data.get('metric', '-')}")
|
||||
console.print(f" Parameters:")
|
||||
for k, v in data["parameters"].items():
|
||||
console.print(f" {k}: {v}")
|
||||
else:
|
||||
console.print("[dim]No trials completed[/dim]")
|
||||
|
||||
|
||||
@tune.command(name="stop")
|
||||
@click.argument("name")
|
||||
def tune_stop(name: str):
|
||||
"""Stop a tuning job."""
|
||||
resp = _request("post", f"/v1/tune/{name}/stop")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Tuning job '{name}' stopped")
|
||||
|
||||
|
||||
@@ -503,33 +783,62 @@ def features():
|
||||
@click.option("--source", help="Data source (table, stream)")
|
||||
def features_create(name: str, description: str, schema: str, source: str):
|
||||
"""Create a feature group."""
|
||||
body = {"name": name}
|
||||
if description:
|
||||
body["description"] = description
|
||||
if schema:
|
||||
with open(schema) as f:
|
||||
body["schema"] = json.load(f)
|
||||
if source:
|
||||
body["source"] = source
|
||||
|
||||
resp = _request("post", "/v1/features", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Feature group '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
|
||||
|
||||
@features.command(name="list")
|
||||
def features_list():
|
||||
"""List feature groups."""
|
||||
resp = _request("get", "/v1/features")
|
||||
data = check_response(resp)
|
||||
items = data.get("feature_groups", data.get("items", []))
|
||||
|
||||
table = Table(title="Feature Groups", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Features", style="green")
|
||||
table.add_column("Entities", style="white")
|
||||
table.add_column("Updated", style="dim")
|
||||
|
||||
for fg in items:
|
||||
table.add_row(
|
||||
fg.get("name", ""),
|
||||
str(fg.get("feature_count", 0)),
|
||||
fg.get("entity", "-"),
|
||||
str(fg.get("updated_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No feature groups found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No feature groups found[/dim]")
|
||||
|
||||
|
||||
@features.command(name="describe")
|
||||
@click.argument("name")
|
||||
def features_describe(name: str):
|
||||
"""Show feature group details."""
|
||||
resp = _request("get", f"/v1/features/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Features:[/cyan] 0\n"
|
||||
f"[cyan]Entities:[/cyan] user_id\n"
|
||||
f"[cyan]Source:[/cyan] -\n"
|
||||
f"[cyan]Updated:[/cyan] -",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Features:[/cyan] {data.get('feature_count', 0)}\n"
|
||||
f"[cyan]Entities:[/cyan] {data.get('entity', '-')}\n"
|
||||
f"[cyan]Source:[/cyan] {data.get('source', '-')}\n"
|
||||
f"[cyan]Updated:[/cyan] {str(data.get('updated_at', ''))[:19]}",
|
||||
title="Feature Group",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -543,7 +852,10 @@ def features_describe(name: str):
|
||||
def features_ingest(name: str, source: str, mode: str):
|
||||
"""Ingest data into feature group."""
|
||||
console.print(f"[cyan]Ingesting into '{name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/features/{name}/ingest", json={"source": source, "mode": mode})
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Ingestion complete")
|
||||
console.print(f" Records: {data.get('records', 0)}")
|
||||
|
||||
|
||||
@features.command(name="get")
|
||||
@@ -553,8 +865,22 @@ def features_ingest(name: str, source: str, mode: str):
|
||||
@click.option("--timestamp", "-t", help="Point-in-time (for historical features)")
|
||||
def features_get(feature_group: str, entity: str, features: str, timestamp: str):
|
||||
"""Get feature values."""
|
||||
console.print(f"[cyan]Fetching features for '{entity}'...[/cyan]")
|
||||
console.print("[dim]No features found[/dim]")
|
||||
params = {"entity": entity}
|
||||
if features:
|
||||
params["features"] = features
|
||||
if timestamp:
|
||||
params["timestamp"] = timestamp
|
||||
|
||||
resp = _request("get", f"/v1/features/{feature_group}/values", params=params)
|
||||
data = check_response(resp)
|
||||
values = data.get("values", data.get("features", {}))
|
||||
|
||||
console.print(f"[cyan]Features for '{entity}':[/cyan]")
|
||||
if values:
|
||||
for k, v in values.items():
|
||||
console.print(f" {k}: {v}")
|
||||
else:
|
||||
console.print("[dim]No features found[/dim]")
|
||||
|
||||
|
||||
@features.command(name="materialize")
|
||||
@@ -563,8 +889,17 @@ def features_get(feature_group: str, entity: str, features: str, timestamp: str)
|
||||
@click.option("--end", "-e", help="End time")
|
||||
def features_materialize(name: str, start: str, end: str):
|
||||
"""Materialize features to online store."""
|
||||
body = {}
|
||||
if start:
|
||||
body["start"] = start
|
||||
if end:
|
||||
body["end"] = end
|
||||
|
||||
console.print(f"[cyan]Materializing '{name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/features/{name}/materialize", json=body)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Features materialized to online store")
|
||||
console.print(f" Records: {data.get('records', 0)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -585,7 +920,17 @@ def datasets():
|
||||
@click.option("--split", help="Train/val/test split (e.g., 80:10:10)")
|
||||
def datasets_create(name: str, source: str, fmt: str, split: str):
|
||||
"""Create a versioned dataset."""
|
||||
body = {"name": name, "source": source}
|
||||
if fmt:
|
||||
body["format"] = fmt
|
||||
if split:
|
||||
body["split"] = split
|
||||
|
||||
resp = _request("post", "/v1/datasets", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Dataset '{name}' created")
|
||||
console.print(f" Version: {data.get('version', '1')}")
|
||||
console.print(f" Source: {source}")
|
||||
if split:
|
||||
console.print(f" Split: {split}")
|
||||
@@ -594,6 +939,10 @@ def datasets_create(name: str, source: str, fmt: str, split: str):
|
||||
@datasets.command(name="list")
|
||||
def datasets_list():
|
||||
"""List datasets."""
|
||||
resp = _request("get", "/v1/datasets")
|
||||
data = check_response(resp)
|
||||
items = data.get("datasets", data.get("items", []))
|
||||
|
||||
table = Table(title="Datasets", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Version", style="white")
|
||||
@@ -601,21 +950,43 @@ def datasets_list():
|
||||
table.add_column("Format", style="yellow")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for d in items:
|
||||
table.add_row(
|
||||
d.get("name", ""),
|
||||
str(d.get("version", "-")),
|
||||
d.get("size", "-"),
|
||||
d.get("format", "-"),
|
||||
str(d.get("created_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No datasets found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No datasets found[/dim]")
|
||||
|
||||
|
||||
@datasets.command(name="versions")
|
||||
@click.argument("name")
|
||||
def datasets_versions(name: str):
|
||||
"""List dataset versions."""
|
||||
resp = _request("get", f"/v1/datasets/{name}/versions")
|
||||
data = check_response(resp)
|
||||
items = data.get("versions", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Versions of '{name}'", box=box.ROUNDED)
|
||||
table.add_column("Version", style="cyan")
|
||||
table.add_column("Size", style="green")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for v in items:
|
||||
table.add_row(
|
||||
str(v.get("version", "")),
|
||||
v.get("size", "-"),
|
||||
str(v.get("created_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No versions found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No versions found[/dim]")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -647,8 +1018,16 @@ def automl_create(name: str, dataset: str, target: str, task: str, time_limit: i
|
||||
hanzo ml automl create sales-forecast -d sales -t revenue --task forecasting
|
||||
hanzo ml automl create sentiment -d reviews -t label --task nlp --time-limit 7200
|
||||
"""
|
||||
body = {"name": name, "dataset": dataset, "target": target, "task": task, "time_limit": time_limit}
|
||||
if metric:
|
||||
body["metric"] = metric
|
||||
|
||||
console.print(f"[cyan]Creating AutoML job '{name}'...[/cyan]")
|
||||
resp = _request("post", "/v1/automl", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] AutoML job started")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Dataset: {dataset}")
|
||||
console.print(f" Target: {target}")
|
||||
console.print(f" Task: {task}")
|
||||
@@ -658,6 +1037,10 @@ def automl_create(name: str, dataset: str, target: str, task: str, time_limit: i
|
||||
@automl.command(name="list")
|
||||
def automl_list():
|
||||
"""List AutoML jobs."""
|
||||
resp = _request("get", "/v1/automl")
|
||||
data = check_response(resp)
|
||||
items = data.get("jobs", data.get("items", []))
|
||||
|
||||
table = Table(title="AutoML Jobs", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Task", style="white")
|
||||
@@ -665,22 +1048,40 @@ def automl_list():
|
||||
table.add_column("Metric", style="yellow")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for j in items:
|
||||
j_status = j.get("status", "unknown")
|
||||
style = {"running": "cyan", "completed": "green", "failed": "red"}.get(j_status, "white")
|
||||
table.add_row(
|
||||
j.get("name", ""),
|
||||
j.get("task", "-"),
|
||||
j.get("best_model", "-"),
|
||||
str(j.get("best_metric", "-")),
|
||||
f"[{style}]{j_status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No AutoML jobs found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No AutoML jobs found[/dim]")
|
||||
|
||||
|
||||
@automl.command(name="status")
|
||||
@click.argument("name")
|
||||
def automl_status(name: str):
|
||||
"""Show AutoML job status."""
|
||||
resp = _request("get", f"/v1/automl/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "unknown")
|
||||
status_style = {"running": "cyan", "completed": "green", "failed": "red"}.get(status, "yellow")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Job:[/cyan] {name}\n"
|
||||
f"[cyan]Status:[/cyan] [yellow]Running[/yellow]\n"
|
||||
f"[cyan]Progress:[/cyan] 0%\n"
|
||||
f"[cyan]Models tried:[/cyan] 0\n"
|
||||
f"[cyan]Best so far:[/cyan] -\n"
|
||||
f"[cyan]Time remaining:[/cyan] -",
|
||||
f"[cyan]Job:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Status:[/cyan] [{status_style}]{status}[/{status_style}]\n"
|
||||
f"[cyan]Progress:[/cyan] {data.get('progress', 0)}%\n"
|
||||
f"[cyan]Models tried:[/cyan] {data.get('models_tried', 0)}\n"
|
||||
f"[cyan]Best so far:[/cyan] {data.get('best_metric', '-')}\n"
|
||||
f"[cyan]Time remaining:[/cyan] {data.get('time_remaining', '-')}",
|
||||
title="AutoML Status",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -692,14 +1093,27 @@ def automl_status(name: str):
|
||||
@click.option("--top", "-n", default=10, help="Show top N models")
|
||||
def automl_leaderboard(name: str, top: int):
|
||||
"""Show AutoML leaderboard."""
|
||||
resp = _request("get", f"/v1/automl/{name}/leaderboard", params={"top": top})
|
||||
data = check_response(resp)
|
||||
items = data.get("models", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Leaderboard for '{name}'", box=box.ROUNDED)
|
||||
table.add_column("Rank", style="cyan")
|
||||
table.add_column("Model", style="white")
|
||||
table.add_column("Metric", style="green")
|
||||
table.add_column("Training Time", style="dim")
|
||||
|
||||
for i, m in enumerate(items, 1):
|
||||
table.add_row(
|
||||
str(i),
|
||||
m.get("model", "-"),
|
||||
str(m.get("metric", "-")),
|
||||
f"{m.get('training_time_s', '-')}s" if m.get("training_time_s") else "-",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No models trained yet[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No models trained yet[/dim]")
|
||||
|
||||
|
||||
@automl.command(name="deploy")
|
||||
@@ -708,6 +1122,16 @@ def automl_leaderboard(name: str, top: int):
|
||||
@click.option("--endpoint", "-e", help="Endpoint name")
|
||||
def automl_deploy(name: str, model: str, endpoint: str):
|
||||
"""Deploy best AutoML model."""
|
||||
body = {}
|
||||
if model:
|
||||
body["model"] = model
|
||||
if endpoint:
|
||||
body["endpoint"] = endpoint
|
||||
|
||||
console.print(f"[cyan]Deploying best model from '{name}'...[/cyan]")
|
||||
resp = _request("post", f"/v1/automl/{name}/deploy", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Model deployed")
|
||||
console.print(f" Endpoint: https://serving.hanzo.ai/{endpoint or name}")
|
||||
console.print(f" Endpoint: {data.get('endpoint', '-')}")
|
||||
console.print(f" Model: {data.get('model', '-')}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,23 @@
|
||||
Edge, HKE, networking, tunnel, DNS, guard.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
SERVICE_URL = os.getenv("HANZO_PLATFORM_URL", "https://platform.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(SERVICE_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="platform")
|
||||
@@ -52,27 +63,44 @@ def edge():
|
||||
@edge.command(name="list")
|
||||
def edge_list():
|
||||
"""List edge deployments."""
|
||||
resp = _request("get", "/v1/edge/deployments")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Edge Deployments", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("URL", style="white")
|
||||
table.add_column("Regions", style="dim")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for d in data.get("deployments", []):
|
||||
st = d.get("status", "active")
|
||||
st_style = "green" if st == "active" else "yellow"
|
||||
table.add_row(
|
||||
d.get("name", ""),
|
||||
d.get("url", ""),
|
||||
", ".join(d.get("regions", [])),
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@edge.command(name="deploy")
|
||||
@click.option("--name", "-n", prompt=True, help="Deployment name")
|
||||
@click.option("--dir", "-d", default=".", help="Directory to deploy")
|
||||
@click.option("--regions", "-r", default="all", help="Target regions")
|
||||
def edge_deploy(name: str, dir: str, regions: str):
|
||||
@click.option("--dir", "-d", "source_dir", default=".", help="Directory to deploy")
|
||||
@click.option("--regions", "-r", default="all", help="Target regions (comma-separated or 'all')")
|
||||
def edge_deploy(name: str, source_dir: str, regions: str):
|
||||
"""Deploy to edge locations."""
|
||||
console.print(f"[cyan]Deploying '{name}' to edge...[/cyan]")
|
||||
console.print(f" Source: {dir}")
|
||||
console.print(f" Regions: {regions}")
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] Deployed to edge")
|
||||
console.print(f"[dim]URL: https://{name}.edge.hanzo.ai[/dim]")
|
||||
region_list = [r.strip() for r in regions.split(",")] if regions != "all" else ["all"]
|
||||
resp = _request("post", "/v1/edge/deployments", json={
|
||||
"name": name,
|
||||
"source_dir": source_dir,
|
||||
"regions": region_list,
|
||||
})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Deployed '{name}' to edge")
|
||||
console.print(f"[dim]URL: {data.get('url', f'https://{name}.edge.hanzo.ai')}[/dim]")
|
||||
|
||||
|
||||
@edge.command(name="purge")
|
||||
@@ -80,12 +108,44 @@ def edge_deploy(name: str, dir: str, regions: str):
|
||||
@click.option("--path", "-p", help="Specific path to purge")
|
||||
def edge_purge(name: str, path: str):
|
||||
"""Purge edge cache."""
|
||||
payload: dict = {}
|
||||
if path:
|
||||
payload["path"] = path
|
||||
resp = _request("post", f"/v1/edge/deployments/{name}/purge", json=payload)
|
||||
check_response(resp)
|
||||
if path:
|
||||
console.print(f"[green]✓[/green] Purged cache for {path}")
|
||||
else:
|
||||
console.print(f"[green]✓[/green] Purged all cache for {name}")
|
||||
|
||||
|
||||
@edge.command(name="delete")
|
||||
@click.argument("name")
|
||||
def edge_delete(name: str):
|
||||
"""Delete an edge deployment."""
|
||||
resp = _request("delete", f"/v1/edge/deployments/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Edge deployment '{name}' deleted")
|
||||
|
||||
|
||||
@edge.command(name="stats")
|
||||
@click.argument("name")
|
||||
@click.option("--period", "-p", default="24h", help="Time period")
|
||||
def edge_stats(name: str, period: str):
|
||||
"""Show edge deployment statistics."""
|
||||
resp = _request("get", f"/v1/edge/deployments/{name}/stats", params={"period": period})
|
||||
data = check_response(resp)
|
||||
|
||||
info = (
|
||||
f"[cyan]Deployment:[/cyan] {name}\n"
|
||||
f"[cyan]Requests:[/cyan] {data.get('requests', 0):,}\n"
|
||||
f"[cyan]Bandwidth:[/cyan] {data.get('bandwidth', 'N/A')}\n"
|
||||
f"[cyan]Cache Hit Rate:[/cyan] {data.get('cache_hit_rate', 0):.0f}%\n"
|
||||
f"[cyan]Avg Latency:[/cyan] {data.get('avg_latency', 'N/A')}"
|
||||
)
|
||||
console.print(Panel(info, title="Edge Stats", border_style="cyan"))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HKE (Kubernetes)
|
||||
# ============================================================================
|
||||
@@ -100,6 +160,9 @@ def hke():
|
||||
@hke.command(name="list")
|
||||
def hke_list():
|
||||
"""List Kubernetes clusters."""
|
||||
resp = _request("get", "/v1/hke/clusters")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="HKE Clusters", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Version", style="white")
|
||||
@@ -107,6 +170,17 @@ def hke_list():
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Region", style="dim")
|
||||
|
||||
for c in data.get("clusters", []):
|
||||
st = c.get("status", "running")
|
||||
st_style = {"running": "green", "provisioning": "yellow", "error": "red"}.get(st, "white")
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
c.get("version", ""),
|
||||
str(c.get("node_count", 0)),
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
c.get("region", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -116,34 +190,60 @@ def hke_list():
|
||||
@click.option("--nodes", default=3, help="Number of nodes")
|
||||
@click.option("--region", "-r", default="us-west-2", help="Region")
|
||||
@click.option("--gpu", is_flag=True, help="Enable GPU node pool")
|
||||
def hke_create(name: str, version: str, nodes: int, region: str, gpu: bool):
|
||||
@click.option("--node-size", default="s-4vcpu-8gb", help="Node size")
|
||||
def hke_create(name: str, version: str, nodes: int, region: str, gpu: bool, node_size: str):
|
||||
"""Create a Kubernetes cluster."""
|
||||
console.print(f"[cyan]Creating cluster '{name}'...[/cyan]")
|
||||
resp = _request("post", "/v1/hke/clusters", json={
|
||||
"name": name,
|
||||
"version": version,
|
||||
"node_count": nodes,
|
||||
"region": region,
|
||||
"gpu": gpu,
|
||||
"node_size": node_size,
|
||||
})
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Cluster '{name}' creation initiated")
|
||||
console.print(f" Version: {version}")
|
||||
console.print(f" Nodes: {nodes}")
|
||||
console.print(f" Region: {region}")
|
||||
console.print(f" GPU: {'Yes' if gpu else 'No'}")
|
||||
console.print()
|
||||
console.print(f"[green]✓[/green] Cluster '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '')}")
|
||||
|
||||
|
||||
@hke.command(name="delete")
|
||||
@click.argument("name")
|
||||
def hke_delete(name: str):
|
||||
@click.option("--confirm", "confirmed", is_flag=True, help="Skip confirmation")
|
||||
def hke_delete(name: str, confirmed: bool):
|
||||
"""Delete a Kubernetes cluster."""
|
||||
from rich.prompt import Confirm
|
||||
|
||||
if not Confirm.ask(f"[red]Delete cluster '{name}'? This cannot be undone.[/red]"):
|
||||
return
|
||||
console.print(f"[green]✓[/green] Cluster '{name}' deleted")
|
||||
if not confirmed:
|
||||
from rich.prompt import Confirm
|
||||
if not Confirm.ask(f"[red]Delete cluster '{name}'? This cannot be undone.[/red]"):
|
||||
return
|
||||
resp = _request("delete", f"/v1/hke/clusters/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Cluster '{name}' deletion initiated")
|
||||
|
||||
|
||||
@hke.command(name="kubeconfig")
|
||||
@click.argument("name")
|
||||
def hke_kubeconfig(name: str):
|
||||
@click.option("--output", "-o", help="Output file path")
|
||||
def hke_kubeconfig(name: str, output: str):
|
||||
"""Get kubeconfig for a cluster."""
|
||||
console.print(f"[cyan]Fetching kubeconfig for '{name}'...[/cyan]")
|
||||
console.print("[green]✓[/green] Kubeconfig saved to ~/.kube/config")
|
||||
resp = _request("get", f"/v1/hke/clusters/{name}/kubeconfig")
|
||||
data = check_response(resp)
|
||||
|
||||
kubeconfig = data.get("kubeconfig", "")
|
||||
if output:
|
||||
with open(output, "w") as f:
|
||||
f.write(kubeconfig)
|
||||
console.print(f"[green]✓[/green] Kubeconfig saved to {output}")
|
||||
else:
|
||||
import os as _os
|
||||
kube_path = _os.path.expanduser("~/.kube/config")
|
||||
with open(kube_path, "w") as f:
|
||||
f.write(kubeconfig)
|
||||
console.print(f"[green]✓[/green] Kubeconfig saved to ~/.kube/config")
|
||||
|
||||
|
||||
@hke.command(name="scale")
|
||||
@@ -151,9 +251,21 @@ def hke_kubeconfig(name: str):
|
||||
@click.option("--nodes", "-n", required=True, type=int, help="Target nodes")
|
||||
def hke_scale(name: str, nodes: int):
|
||||
"""Scale cluster nodes."""
|
||||
resp = _request("post", f"/v1/hke/clusters/{name}/scale", json={"node_count": nodes})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Cluster '{name}' scaling to {nodes} nodes")
|
||||
|
||||
|
||||
@hke.command(name="upgrade")
|
||||
@click.argument("name")
|
||||
@click.option("--version", "-v", required=True, help="Target Kubernetes version")
|
||||
def hke_upgrade(name: str, version: str):
|
||||
"""Upgrade cluster Kubernetes version."""
|
||||
resp = _request("post", f"/v1/hke/clusters/{name}/upgrade", json={"version": version})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Cluster '{name}' upgrading to {version}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tunnel
|
||||
# ============================================================================
|
||||
@@ -171,25 +283,59 @@ def tunnel():
|
||||
@click.option("--auth", is_flag=True, help="Require authentication")
|
||||
def tunnel_share(target: str, name: str, auth: bool):
|
||||
"""Share local service via secure tunnel."""
|
||||
subdomain = name or "random-xxx"
|
||||
console.print(f"[cyan]Creating tunnel to {target}...[/cyan]")
|
||||
console.print()
|
||||
payload: dict = {"target": target}
|
||||
if name:
|
||||
payload["subdomain"] = name
|
||||
if auth:
|
||||
payload["auth_required"] = True
|
||||
|
||||
resp = _request("post", "/v1/tunnels", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
subdomain = data.get("subdomain", name or "random")
|
||||
url = data.get("url", f"https://{subdomain}.tunnel.hanzo.ai")
|
||||
|
||||
console.print(f"[green]✓[/green] Tunnel active")
|
||||
console.print(f" [cyan]Public URL:[/cyan] https://{subdomain}.tunnel.hanzo.ai")
|
||||
console.print(f" [cyan]Public URL:[/cyan] {url}")
|
||||
console.print(f" [cyan]Target:[/cyan] {target}")
|
||||
if auth:
|
||||
console.print(f" [cyan]Auth:[/cyan] Required")
|
||||
console.print()
|
||||
console.print("Press Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
tunnel_id = data.get("id", "")
|
||||
import time
|
||||
while True:
|
||||
time.sleep(30)
|
||||
_request("post", f"/v1/tunnels/{tunnel_id}/keepalive")
|
||||
except KeyboardInterrupt:
|
||||
_request("delete", f"/v1/tunnels/{tunnel_id}")
|
||||
console.print("\n[dim]Tunnel closed[/dim]")
|
||||
|
||||
|
||||
@tunnel.command(name="list")
|
||||
def tunnel_list():
|
||||
"""List active tunnels."""
|
||||
resp = _request("get", "/v1/tunnels")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Active Tunnels", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Target", style="white")
|
||||
table.add_column("URL", style="dim")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for t in data.get("tunnels", []):
|
||||
st = t.get("status", "active")
|
||||
st_style = "green" if st == "active" else "yellow"
|
||||
table.add_row(
|
||||
t.get("subdomain", ""),
|
||||
t.get("target", ""),
|
||||
t.get("url", ""),
|
||||
f"[{st_style}]{st}[/{st_style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -197,6 +343,8 @@ def tunnel_list():
|
||||
@click.argument("name")
|
||||
def tunnel_stop(name: str):
|
||||
"""Stop a tunnel."""
|
||||
resp = _request("delete", f"/v1/tunnels/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Tunnel '{name}' stopped")
|
||||
|
||||
|
||||
@@ -211,15 +359,51 @@ def dns():
|
||||
pass
|
||||
|
||||
|
||||
@dns.command(name="zones")
|
||||
def dns_zones():
|
||||
"""List DNS zones."""
|
||||
resp = _request("get", "/v1/dns/zones")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="DNS Zones", box=box.ROUNDED)
|
||||
table.add_column("Zone", style="cyan")
|
||||
table.add_column("Records", style="white")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for z in data.get("zones", []):
|
||||
table.add_row(
|
||||
z.get("name", ""),
|
||||
str(z.get("record_count", 0)),
|
||||
z.get("status", "active"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@dns.command(name="list")
|
||||
@click.argument("zone", required=False)
|
||||
def dns_list(zone: str):
|
||||
"""List DNS records."""
|
||||
path = f"/v1/dns/zones/{zone}/records" if zone else "/v1/dns/records"
|
||||
resp = _request("get", path)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title=f"DNS Records{' for ' + zone if zone else ''}", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Type", style="white")
|
||||
table.add_column("Value", style="dim")
|
||||
table.add_column("TTL", style="dim")
|
||||
table.add_column("Proxied", style="green")
|
||||
|
||||
for r in data.get("records", []):
|
||||
proxied = r.get("proxied", False)
|
||||
table.add_row(
|
||||
r.get("name", ""),
|
||||
r.get("type", ""),
|
||||
r.get("value", ""),
|
||||
str(r.get("ttl", 300)),
|
||||
"[green]yes[/green]" if proxied else "[dim]no[/dim]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
@@ -227,21 +411,32 @@ def dns_list(zone: str):
|
||||
@dns.command(name="create")
|
||||
@click.option("--zone", "-z", required=True, help="DNS zone")
|
||||
@click.option("--name", "-n", required=True, help="Record name")
|
||||
@click.option("--type", "-t", required=True, help="Record type (A, CNAME, etc.)")
|
||||
@click.option("--type", "-t", "record_type", required=True, help="Record type (A, CNAME, etc.)")
|
||||
@click.option("--value", "-v", required=True, help="Record value")
|
||||
@click.option("--ttl", default=300, help="TTL in seconds")
|
||||
def dns_create(zone: str, name: str, type: str, value: str, ttl: int):
|
||||
@click.option("--proxied", is_flag=True, help="Enable proxy")
|
||||
def dns_create(zone: str, name: str, record_type: str, value: str, ttl: int, proxied: bool):
|
||||
"""Create a DNS record."""
|
||||
console.print(f"[green]✓[/green] DNS record created: {name}.{zone} {type} {value}")
|
||||
resp = _request("post", f"/v1/dns/zones/{zone}/records", json={
|
||||
"name": name,
|
||||
"type": record_type,
|
||||
"value": value,
|
||||
"ttl": ttl,
|
||||
"proxied": proxied,
|
||||
})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] DNS record created: {name}.{zone} {record_type} {value}")
|
||||
|
||||
|
||||
@dns.command(name="delete")
|
||||
@click.option("--zone", "-z", required=True)
|
||||
@click.option("--name", "-n", required=True)
|
||||
@click.option("--type", "-t", required=True)
|
||||
def dns_delete(zone: str, name: str, type: str):
|
||||
@click.option("--type", "-t", "record_type", required=True)
|
||||
def dns_delete(zone: str, name: str, record_type: str):
|
||||
"""Delete a DNS record."""
|
||||
console.print(f"[green]✓[/green] DNS record deleted")
|
||||
resp = _request("delete", f"/v1/dns/zones/{zone}/records", params={"name": name, "type": record_type})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] DNS record deleted: {name}.{zone} {record_type}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -257,47 +452,109 @@ def guard():
|
||||
|
||||
@guard.command(name="enable")
|
||||
@click.option("--project", "-p", help="Project ID")
|
||||
def guard_enable(project: str):
|
||||
@click.option("--mode", "-m", type=click.Choice(["block", "warn", "log"]), default="block", help="Enforcement mode")
|
||||
def guard_enable(project: str, mode: str):
|
||||
"""Enable LLM guard for project."""
|
||||
console.print("[green]✓[/green] Guard enabled")
|
||||
payload: dict = {"enabled": True, "mode": mode}
|
||||
if project:
|
||||
payload["project_id"] = project
|
||||
resp = _request("post", "/v1/guard/config", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Guard enabled (mode: {mode})")
|
||||
console.print("[dim]All LLM requests will be scanned for:[/dim]")
|
||||
console.print(" • Prompt injection")
|
||||
console.print(" • PII leakage")
|
||||
console.print(" • Harmful content")
|
||||
console.print(" - Prompt injection")
|
||||
console.print(" - PII leakage")
|
||||
console.print(" - Harmful content")
|
||||
|
||||
|
||||
@guard.command(name="disable")
|
||||
@click.option("--project", "-p", help="Project ID")
|
||||
def guard_disable(project: str):
|
||||
"""Disable LLM guard."""
|
||||
console.print("[yellow]⚠[/yellow] Guard disabled")
|
||||
payload: dict = {"enabled": False}
|
||||
if project:
|
||||
payload["project_id"] = project
|
||||
resp = _request("post", "/v1/guard/config", json=payload)
|
||||
check_response(resp)
|
||||
console.print("[yellow]Guard disabled[/yellow]")
|
||||
|
||||
|
||||
@guard.command(name="status")
|
||||
def guard_status():
|
||||
@click.option("--project", "-p", help="Project ID")
|
||||
def guard_status(project: str):
|
||||
"""Show guard status and stats."""
|
||||
console.print(
|
||||
Panel(
|
||||
"[cyan]Status:[/cyan] Active\n"
|
||||
"[cyan]Requests scanned:[/cyan] 12,456\n"
|
||||
"[cyan]Threats blocked:[/cyan] 23\n"
|
||||
"[cyan]PII detected:[/cyan] 145",
|
||||
title="Guard Status",
|
||||
border_style="cyan",
|
||||
)
|
||||
params: dict = {}
|
||||
if project:
|
||||
params["project_id"] = project
|
||||
resp = _request("get", "/v1/guard/status", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
enabled = data.get("enabled", False)
|
||||
status_str = "[green]Active[/green]" if enabled else "[red]Disabled[/red]"
|
||||
|
||||
info = (
|
||||
f"[cyan]Status:[/cyan] {status_str}\n"
|
||||
f"[cyan]Mode:[/cyan] {data.get('mode', 'N/A')}\n"
|
||||
f"[cyan]Requests scanned:[/cyan] {data.get('requests_scanned', 0):,}\n"
|
||||
f"[cyan]Threats blocked:[/cyan] {data.get('threats_blocked', 0):,}\n"
|
||||
f"[cyan]PII detected:[/cyan] {data.get('pii_detected', 0):,}\n"
|
||||
f"[cyan]Injections caught:[/cyan] {data.get('injections_caught', 0):,}"
|
||||
)
|
||||
console.print(Panel(info, title="Guard Status", border_style="cyan"))
|
||||
|
||||
|
||||
@guard.command(name="logs")
|
||||
@click.option("--limit", "-n", default=50, help="Number of logs")
|
||||
def guard_logs(limit: int):
|
||||
@click.option("--type", "-t", "log_type", type=click.Choice(["all", "blocked", "warned", "pii", "injection"]), default="all")
|
||||
def guard_logs(limit: int, log_type: str):
|
||||
"""View guard logs."""
|
||||
params: dict = {"limit": limit}
|
||||
if log_type != "all":
|
||||
params["type"] = log_type
|
||||
resp = _request("get", "/v1/guard/logs", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Guard Logs", box=box.ROUNDED)
|
||||
table.add_column("Time", style="dim")
|
||||
table.add_column("Type", style="yellow")
|
||||
table.add_column("Action", style="green")
|
||||
table.add_column("Details", style="white")
|
||||
|
||||
for entry in data.get("logs", []):
|
||||
action = entry.get("action", "log")
|
||||
action_style = {"blocked": "red", "warned": "yellow"}.get(action, "green")
|
||||
table.add_row(
|
||||
entry.get("timestamp", ""),
|
||||
entry.get("type", ""),
|
||||
f"[{action_style}]{action}[/{action_style}]",
|
||||
entry.get("details", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@guard.command(name="rules")
|
||||
def guard_rules():
|
||||
"""List guard rules."""
|
||||
resp = _request("get", "/v1/guard/rules")
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Guard Rules", box=box.ROUNDED)
|
||||
table.add_column("Rule", style="cyan")
|
||||
table.add_column("Category", style="white")
|
||||
table.add_column("Action", style="green")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for r in data.get("rules", []):
|
||||
enabled = r.get("enabled", True)
|
||||
status_str = "[green]active[/green]" if enabled else "[dim]disabled[/dim]"
|
||||
table.add_row(
|
||||
r.get("name", ""),
|
||||
r.get("category", ""),
|
||||
r.get("action", "block"),
|
||||
status_str,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@@ -313,40 +570,84 @@ def kms():
|
||||
|
||||
|
||||
@kms.command(name="list")
|
||||
def kms_list():
|
||||
@click.option("--workspace", "-w", help="Workspace ID")
|
||||
def kms_list(workspace: str):
|
||||
"""List secrets."""
|
||||
params: dict = {}
|
||||
if workspace:
|
||||
params["workspace"] = workspace
|
||||
resp = _request("get", "/v1/kms/secrets", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
table = Table(title="Secrets", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Version", style="white")
|
||||
table.add_column("Updated", style="dim")
|
||||
|
||||
for s in data.get("secrets", []):
|
||||
table.add_row(
|
||||
s.get("name", ""),
|
||||
str(s.get("version", 1)),
|
||||
s.get("updated_at", ""),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@kms.command(name="set")
|
||||
@click.argument("name")
|
||||
@click.option("--value", "-v", prompt=True, hide_input=True, help="Secret value")
|
||||
def kms_set(name: str, value: str):
|
||||
@click.option("--workspace", "-w", help="Workspace ID")
|
||||
def kms_set(name: str, value: str, workspace: str):
|
||||
"""Set a secret."""
|
||||
payload: dict = {"name": name, "value": value}
|
||||
if workspace:
|
||||
payload["workspace"] = workspace
|
||||
resp = _request("post", "/v1/kms/secrets", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Secret '{name}' set")
|
||||
|
||||
|
||||
@kms.command(name="get")
|
||||
@click.argument("name")
|
||||
def kms_get(name: str):
|
||||
@click.option("--workspace", "-w", help="Workspace ID")
|
||||
@click.option("--reveal", is_flag=True, help="Show actual value")
|
||||
def kms_get(name: str, workspace: str, reveal: bool):
|
||||
"""Get a secret value."""
|
||||
console.print(f"[yellow]Secret value:[/yellow] ********")
|
||||
params: dict = {}
|
||||
if workspace:
|
||||
params["workspace"] = workspace
|
||||
resp = _request("get", f"/v1/kms/secrets/{name}", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
if reveal:
|
||||
console.print(f"[yellow]Secret value:[/yellow] {data.get('value', '')}")
|
||||
else:
|
||||
console.print(f"[yellow]Secret value:[/yellow] ********")
|
||||
console.print("[dim]Use --reveal to show actual value[/dim]")
|
||||
|
||||
|
||||
@kms.command(name="delete")
|
||||
@click.argument("name")
|
||||
def kms_delete(name: str):
|
||||
@click.option("--workspace", "-w", help="Workspace ID")
|
||||
def kms_delete(name: str, workspace: str):
|
||||
"""Delete a secret."""
|
||||
params: dict = {}
|
||||
if workspace:
|
||||
params["workspace"] = workspace
|
||||
resp = _request("delete", f"/v1/kms/secrets/{name}", params=params)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Secret '{name}' deleted")
|
||||
|
||||
|
||||
@kms.command(name="rotate")
|
||||
@click.argument("name")
|
||||
def kms_rotate(name: str):
|
||||
@click.option("--workspace", "-w", help="Workspace ID")
|
||||
def kms_rotate(name: str, workspace: str):
|
||||
"""Rotate a secret."""
|
||||
payload: dict = {}
|
||||
if workspace:
|
||||
payload["workspace"] = workspace
|
||||
resp = _request("post", f"/v1/kms/secrets/{name}/rotate", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Secret '{name}' rotated")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Topics, subscriptions, publish, consume.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
PUBSUB_URL = os.getenv("HANZO_PUBSUB_URL", "https://pubsub.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(PUBSUB_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="pubsub")
|
||||
@@ -52,6 +63,14 @@ def topics():
|
||||
@click.option("--project", "-p", help="Project ID")
|
||||
def topics_list(project: str):
|
||||
"""List all topics."""
|
||||
params = {}
|
||||
if project:
|
||||
params["project"] = project
|
||||
|
||||
resp = _request("get", "/v1/topics", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("topics", data.get("items", []))
|
||||
|
||||
table = Table(title="Topics", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Subscriptions", style="white")
|
||||
@@ -59,8 +78,18 @@ def topics_list(project: str):
|
||||
table.add_column("Retention", style="dim")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for t in items:
|
||||
table.add_row(
|
||||
t.get("name", ""),
|
||||
str(t.get("subscription_count", 0)),
|
||||
str(t.get("messages_per_day", 0)),
|
||||
t.get("retention", "-"),
|
||||
str(t.get("created_at", ""))[:10],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No topics found. Create one with 'hanzo pubsub topics create'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No topics found. Create one with 'hanzo pubsub topics create'[/dim]")
|
||||
|
||||
|
||||
@topics.command(name="create")
|
||||
@@ -69,7 +98,15 @@ def topics_list(project: str):
|
||||
@click.option("--schema", "-s", help="Schema for message validation")
|
||||
def topics_create(name: str, retention: str, schema: str):
|
||||
"""Create a topic."""
|
||||
payload = {"name": name, "retention": retention}
|
||||
if schema:
|
||||
payload["schema"] = schema
|
||||
|
||||
resp = _request("post", "/v1/topics", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Topic '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Retention: {retention}")
|
||||
if schema:
|
||||
console.print(f" Schema: {schema}")
|
||||
@@ -85,6 +122,9 @@ def topics_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete topic '{name}' and all subscriptions?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/topics/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Topic '{name}' deleted")
|
||||
|
||||
|
||||
@@ -92,14 +132,17 @@ def topics_delete(name: str, force: bool):
|
||||
@click.argument("name")
|
||||
def topics_describe(name: str):
|
||||
"""Show topic details."""
|
||||
resp = _request("get", f"/v1/topics/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Topic:[/cyan] {name}\n"
|
||||
f"[cyan]Subscriptions:[/cyan] 3\n"
|
||||
f"[cyan]Messages/day:[/cyan] 125,000\n"
|
||||
f"[cyan]Retention:[/cyan] 7 days\n"
|
||||
f"[cyan]Schema:[/cyan] None\n"
|
||||
f"[cyan]Created:[/cyan] 2024-01-15",
|
||||
f"[cyan]Topic:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Subscriptions:[/cyan] {data.get('subscription_count', 0)}\n"
|
||||
f"[cyan]Messages/day:[/cyan] {data.get('messages_per_day', 0):,}\n"
|
||||
f"[cyan]Retention:[/cyan] {data.get('retention', '-')}\n"
|
||||
f"[cyan]Schema:[/cyan] {data.get('schema', 'None')}\n"
|
||||
f"[cyan]Created:[/cyan] {str(data.get('created_at', ''))[:19]}",
|
||||
title="Topic Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -121,6 +164,14 @@ def subs():
|
||||
@click.option("--topic", "-t", help="Filter by topic")
|
||||
def subs_list(topic: str):
|
||||
"""List subscriptions."""
|
||||
params = {}
|
||||
if topic:
|
||||
params["topic"] = topic
|
||||
|
||||
resp = _request("get", "/v1/subscriptions", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("subscriptions", data.get("items", []))
|
||||
|
||||
table = Table(title="Subscriptions", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Topic", style="white")
|
||||
@@ -128,7 +179,19 @@ def subs_list(topic: str):
|
||||
table.add_column("Pending", style="yellow")
|
||||
table.add_column("Ack Deadline", style="dim")
|
||||
|
||||
for s in items:
|
||||
sub_type = "push" if s.get("push_endpoint") else "pull"
|
||||
table.add_row(
|
||||
s.get("name", ""),
|
||||
s.get("topic", "-"),
|
||||
sub_type,
|
||||
str(s.get("pending_count", 0)),
|
||||
f"{s.get('ack_deadline', 10)}s",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No subscriptions found[/dim]")
|
||||
|
||||
|
||||
@subs.command(name="create")
|
||||
@@ -139,6 +202,15 @@ def subs_list(topic: str):
|
||||
@click.option("--filter", "-f", help="Message filter expression")
|
||||
def subs_create(name: str, topic: str, push_endpoint: str, ack_deadline: int, filter: str):
|
||||
"""Create a subscription."""
|
||||
payload = {"name": name, "topic": topic, "ack_deadline": ack_deadline}
|
||||
if push_endpoint:
|
||||
payload["push_endpoint"] = push_endpoint
|
||||
if filter:
|
||||
payload["filter"] = filter
|
||||
|
||||
resp = _request("post", "/v1/subscriptions", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
sub_type = "push" if push_endpoint else "pull"
|
||||
console.print(f"[green]✓[/green] Subscription '{name}' created")
|
||||
console.print(f" Topic: {topic}")
|
||||
@@ -154,6 +226,8 @@ def subs_create(name: str, topic: str, push_endpoint: str, ack_deadline: int, fi
|
||||
@click.argument("name")
|
||||
def subs_delete(name: str):
|
||||
"""Delete a subscription."""
|
||||
resp = _request("delete", f"/v1/subscriptions/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Subscription '{name}' deleted")
|
||||
|
||||
|
||||
@@ -161,14 +235,18 @@ def subs_delete(name: str):
|
||||
@click.argument("name")
|
||||
def subs_describe(name: str):
|
||||
"""Show subscription details."""
|
||||
resp = _request("get", f"/v1/subscriptions/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
sub_type = "Push" if data.get("push_endpoint") else "Pull"
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Subscription:[/cyan] {name}\n"
|
||||
f"[cyan]Topic:[/cyan] events\n"
|
||||
f"[cyan]Type:[/cyan] Pull\n"
|
||||
f"[cyan]Pending messages:[/cyan] 1,234\n"
|
||||
f"[cyan]Ack deadline:[/cyan] 10s\n"
|
||||
f"[cyan]Filter:[/cyan] None",
|
||||
f"[cyan]Subscription:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Topic:[/cyan] {data.get('topic', '-')}\n"
|
||||
f"[cyan]Type:[/cyan] {sub_type}\n"
|
||||
f"[cyan]Pending messages:[/cyan] {data.get('pending_count', 0):,}\n"
|
||||
f"[cyan]Ack deadline:[/cyan] {data.get('ack_deadline', 10)}s\n"
|
||||
f"[cyan]Filter:[/cyan] {data.get('filter', 'None')}",
|
||||
title="Subscription Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -186,11 +264,19 @@ def subs_describe(name: str):
|
||||
@click.option("--attributes", "-a", multiple=True, help="Attributes (key=value)")
|
||||
def publish(topic: str, message: str, attributes: tuple):
|
||||
"""Publish a message to a topic."""
|
||||
import secrets
|
||||
payload = {"data": message}
|
||||
if attributes:
|
||||
attrs = {}
|
||||
for a in attributes:
|
||||
k, v = a.split("=", 1)
|
||||
attrs[k] = v
|
||||
payload["attributes"] = attrs
|
||||
|
||||
resp = _request("post", f"/v1/topics/{topic}/publish", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
msg_id = secrets.token_hex(8)
|
||||
console.print(f"[green]✓[/green] Message published to '{topic}'")
|
||||
console.print(f" Message ID: {msg_id}")
|
||||
console.print(f" Message ID: {data.get('message_id', data.get('id', '-'))}")
|
||||
if attributes:
|
||||
console.print(f" Attributes: {', '.join(attributes)}")
|
||||
|
||||
@@ -202,8 +288,31 @@ def publish(topic: str, message: str, attributes: tuple):
|
||||
@click.option("--auto-ack", is_flag=True, help="Automatically acknowledge messages")
|
||||
def pull(subscription: str, max_messages: int, wait: bool, auto_ack: bool):
|
||||
"""Pull messages from a subscription."""
|
||||
console.print(f"[cyan]Pulling from '{subscription}'...[/cyan]")
|
||||
console.print("[dim]No messages available[/dim]")
|
||||
params = {"max_messages": max_messages}
|
||||
if wait:
|
||||
params["wait"] = "true"
|
||||
|
||||
resp = _request("post", f"/v1/subscriptions/{subscription}/pull", json=params)
|
||||
data = check_response(resp)
|
||||
messages = data.get("messages", [])
|
||||
|
||||
for msg in messages:
|
||||
console.print(
|
||||
f"[dim]{msg.get('publish_time', '')}[/dim] "
|
||||
f"[cyan]{msg.get('message_id', msg.get('id', ''))}[/cyan] "
|
||||
f"{msg.get('data', '')}"
|
||||
)
|
||||
if msg.get("attributes"):
|
||||
console.print(f" [dim]attrs: {json.dumps(msg['attributes'])}[/dim]")
|
||||
|
||||
if auto_ack and messages:
|
||||
ack_ids = [m.get("ack_id") for m in messages if m.get("ack_id")]
|
||||
if ack_ids:
|
||||
_request("post", f"/v1/subscriptions/{subscription}/ack", json={"ack_ids": ack_ids})
|
||||
console.print(f"[dim]Auto-acknowledged {len(ack_ids)} message(s)[/dim]")
|
||||
|
||||
if not messages:
|
||||
console.print("[dim]No messages available[/dim]")
|
||||
|
||||
|
||||
@pubsub_group.command()
|
||||
@@ -211,7 +320,9 @@ def pull(subscription: str, max_messages: int, wait: bool, auto_ack: bool):
|
||||
@click.option("--ack-ids", "-a", multiple=True, required=True, help="Ack IDs to acknowledge")
|
||||
def ack(subscription: str, ack_ids: tuple):
|
||||
"""Acknowledge messages."""
|
||||
console.print(f"[green]✓[/green] Acknowledged {len(ack_ids)} messages")
|
||||
resp = _request("post", f"/v1/subscriptions/{subscription}/ack", json={"ack_ids": list(ack_ids)})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Acknowledged {len(ack_ids)} message(s)")
|
||||
|
||||
|
||||
@pubsub_group.command()
|
||||
@@ -220,12 +331,22 @@ def ack(subscription: str, ack_ids: tuple):
|
||||
@click.option("--snapshot", "-s", help="Seek to snapshot")
|
||||
def seek(subscription: str, time: str, snapshot: str):
|
||||
"""Seek subscription to a point in time or snapshot."""
|
||||
if not time and not snapshot:
|
||||
raise click.ClickException("Specify --time or --snapshot")
|
||||
|
||||
payload = {}
|
||||
if time:
|
||||
payload["time"] = time
|
||||
if snapshot:
|
||||
payload["snapshot"] = snapshot
|
||||
|
||||
resp = _request("post", f"/v1/subscriptions/{subscription}/seek", json=payload)
|
||||
check_response(resp)
|
||||
|
||||
if time:
|
||||
console.print(f"[green]✓[/green] Subscription '{subscription}' seeked to {time}")
|
||||
elif snapshot:
|
||||
console.print(f"[green]✓[/green] Subscription '{subscription}' seeked to snapshot '{snapshot}'")
|
||||
else:
|
||||
console.print("[yellow]Specify --time or --snapshot[/yellow]")
|
||||
console.print(f"[green]✓[/green] Subscription '{subscription}' seeked to snapshot '{snapshot}'")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -242,13 +363,27 @@ def snapshots():
|
||||
@snapshots.command(name="list")
|
||||
def snapshots_list():
|
||||
"""List snapshots."""
|
||||
resp = _request("get", "/v1/snapshots")
|
||||
data = check_response(resp)
|
||||
items = data.get("snapshots", data.get("items", []))
|
||||
|
||||
table = Table(title="Snapshots", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Subscription", style="white")
|
||||
table.add_column("Created", style="dim")
|
||||
table.add_column("Expires", style="dim")
|
||||
|
||||
for s in items:
|
||||
table.add_row(
|
||||
s.get("name", ""),
|
||||
s.get("subscription", "-"),
|
||||
str(s.get("created_at", ""))[:19],
|
||||
str(s.get("expires_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not items:
|
||||
console.print("[dim]No snapshots found[/dim]")
|
||||
|
||||
|
||||
@snapshots.command(name="create")
|
||||
@@ -256,6 +391,8 @@ def snapshots_list():
|
||||
@click.option("--subscription", "-s", required=True, help="Subscription to snapshot")
|
||||
def snapshots_create(name: str, subscription: str):
|
||||
"""Create a snapshot of a subscription."""
|
||||
resp = _request("post", "/v1/snapshots", json={"name": name, "subscription": subscription})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Snapshot '{name}' created from '{subscription}'")
|
||||
|
||||
|
||||
@@ -263,4 +400,6 @@ def snapshots_create(name: str, subscription: str):
|
||||
@click.argument("name")
|
||||
def snapshots_delete(name: str):
|
||||
"""Delete a snapshot."""
|
||||
resp = _request("delete", f"/v1/snapshots/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Snapshot '{name}' deleted")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
BullMQ-compatible job and message queues.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
QUEUES_URL = os.getenv("HANZO_QUEUES_URL", "https://queues.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(QUEUES_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="queues")
|
||||
@@ -45,6 +56,14 @@ def queues_group():
|
||||
@click.option("--project", "-p", help="Project ID")
|
||||
def queues_list(project: str):
|
||||
"""List all queues."""
|
||||
params = {}
|
||||
if project:
|
||||
params["project"] = project
|
||||
|
||||
resp = _request("get", "/v1/queues", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("queues", data.get("items", []))
|
||||
|
||||
table = Table(title="Queues", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Pending", style="yellow")
|
||||
@@ -53,8 +72,19 @@ def queues_list(project: str):
|
||||
table.add_column("Failed", style="red")
|
||||
table.add_column("Delayed", style="dim")
|
||||
|
||||
for q in items:
|
||||
table.add_row(
|
||||
q.get("name", ""),
|
||||
str(q.get("pending", 0)),
|
||||
str(q.get("active", 0)),
|
||||
str(q.get("completed", 0)),
|
||||
str(q.get("failed", 0)),
|
||||
str(q.get("delayed", 0)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No queues found. Create one with 'hanzo queues create'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No queues found. Create one with 'hanzo queues create'[/dim]")
|
||||
|
||||
|
||||
@queues_group.command(name="create")
|
||||
@@ -65,7 +95,20 @@ def queues_list(project: str):
|
||||
@click.option("--backoff", default="exponential", help="Backoff strategy")
|
||||
def queues_create(name: str, concurrency: int, rate_limit: str, retry: int, backoff: str):
|
||||
"""Create a queue."""
|
||||
payload = {
|
||||
"name": name,
|
||||
"concurrency": concurrency,
|
||||
"max_retries": retry,
|
||||
"backoff": backoff,
|
||||
}
|
||||
if rate_limit:
|
||||
payload["rate_limit"] = rate_limit
|
||||
|
||||
resp = _request("post", "/v1/queues", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Queue '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Concurrency: {concurrency}")
|
||||
console.print(f" Max retries: {retry}")
|
||||
console.print(f" Backoff: {backoff}")
|
||||
@@ -83,6 +126,9 @@ def queues_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete queue '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/queues/{name}", params={"force": str(force).lower()})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Queue '{name}' deleted")
|
||||
|
||||
|
||||
@@ -90,16 +136,19 @@ def queues_delete(name: str, force: bool):
|
||||
@click.argument("name")
|
||||
def queues_stats(name: str):
|
||||
"""Show queue statistics."""
|
||||
resp = _request("get", f"/v1/queues/{name}/stats")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Queue:[/cyan] {name}\n"
|
||||
f"[cyan]Pending:[/cyan] 1,234\n"
|
||||
f"[cyan]Active:[/cyan] 10\n"
|
||||
f"[cyan]Completed:[/cyan] 45,678 (last 24h)\n"
|
||||
f"[cyan]Failed:[/cyan] 23\n"
|
||||
f"[cyan]Delayed:[/cyan] 56\n"
|
||||
f"[cyan]Avg process time:[/cyan] 245ms\n"
|
||||
f"[cyan]Throughput:[/cyan] 1,200/min",
|
||||
f"[cyan]Queue:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Pending:[/cyan] {data.get('pending', 0):,}\n"
|
||||
f"[cyan]Active:[/cyan] {data.get('active', 0)}\n"
|
||||
f"[cyan]Completed:[/cyan] {data.get('completed', 0):,} (last 24h)\n"
|
||||
f"[cyan]Failed:[/cyan] {data.get('failed', 0)}\n"
|
||||
f"[cyan]Delayed:[/cyan] {data.get('delayed', 0)}\n"
|
||||
f"[cyan]Avg process time:[/cyan] {data.get('avg_process_time_ms', 0)}ms\n"
|
||||
f"[cyan]Throughput:[/cyan] {data.get('throughput_per_min', 0):,}/min",
|
||||
title="Queue Statistics",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -120,11 +169,17 @@ def queues_stats(name: str):
|
||||
@click.option("--attempts", "-a", default=3, help="Max attempts")
|
||||
def queues_push(queue: str, data: str, name: str, priority: int, delay: str, attempts: int):
|
||||
"""Push a job to a queue."""
|
||||
import secrets
|
||||
payload = {"data": json.loads(data), "priority": priority, "max_attempts": attempts}
|
||||
if name:
|
||||
payload["name"] = name
|
||||
if delay:
|
||||
payload["delay"] = delay
|
||||
|
||||
resp = _request("post", f"/v1/queues/{queue}/jobs", json=payload)
|
||||
result = check_response(resp)
|
||||
|
||||
job_id = secrets.token_hex(8)
|
||||
console.print(f"[green]✓[/green] Job pushed to '{queue}'")
|
||||
console.print(f" Job ID: {job_id}")
|
||||
console.print(f" Job ID: {result.get('id', result.get('job_id', '-'))}")
|
||||
if name:
|
||||
console.print(f" Name: {name}")
|
||||
if delay:
|
||||
@@ -138,8 +193,26 @@ def queues_push(queue: str, data: str, name: str, priority: int, delay: str, att
|
||||
@click.option("--wait", "-w", is_flag=True, help="Wait for jobs if none available")
|
||||
def queues_pop(queue: str, count: int, wait: bool):
|
||||
"""Pop jobs from a queue (for workers)."""
|
||||
console.print(f"[cyan]Popping {count} job(s) from '{queue}'...[/cyan]")
|
||||
console.print("[dim]No jobs available[/dim]")
|
||||
params = {"count": count}
|
||||
if wait:
|
||||
params["wait"] = "true"
|
||||
|
||||
resp = _request("post", f"/v1/queues/{queue}/pop", json=params)
|
||||
data = check_response(resp)
|
||||
jobs = data.get("jobs", [])
|
||||
|
||||
for job in jobs:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]ID:[/cyan] {job.get('id', '-')}\n"
|
||||
f"[cyan]Name:[/cyan] {job.get('name', '-')}\n"
|
||||
f"[cyan]Data:[/cyan] {json.dumps(job.get('data', {}), default=str)}",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
if not jobs:
|
||||
console.print("[dim]No jobs available[/dim]")
|
||||
|
||||
|
||||
@queues_group.command(name="peek")
|
||||
@@ -147,6 +220,10 @@ def queues_pop(queue: str, count: int, wait: bool):
|
||||
@click.option("--count", "-n", default=5, help="Number of jobs to peek")
|
||||
def queues_peek(queue: str, count: int):
|
||||
"""Peek at jobs without removing them."""
|
||||
resp = _request("get", f"/v1/queues/{queue}/jobs", params={"limit": count, "status": "pending"})
|
||||
data = check_response(resp)
|
||||
jobs = data.get("jobs", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Next {count} Jobs in '{queue}'", box=box.ROUNDED)
|
||||
table.add_column("Job ID", style="cyan")
|
||||
table.add_column("Name", style="white")
|
||||
@@ -154,8 +231,18 @@ def queues_peek(queue: str, count: int):
|
||||
table.add_column("Attempts", style="dim")
|
||||
table.add_column("Created", style="dim")
|
||||
|
||||
for j in jobs:
|
||||
table.add_row(
|
||||
str(j.get("id", ""))[:16],
|
||||
j.get("name", "-"),
|
||||
str(j.get("priority", 0)),
|
||||
f"{j.get('attempts', 0)}/{j.get('max_attempts', 3)}",
|
||||
str(j.get("created_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No pending jobs[/dim]")
|
||||
if not jobs:
|
||||
console.print("[dim]No pending jobs[/dim]")
|
||||
|
||||
|
||||
@queues_group.command(name="get")
|
||||
@@ -163,15 +250,29 @@ def queues_peek(queue: str, count: int):
|
||||
@click.argument("job_id")
|
||||
def queues_get(queue: str, job_id: str):
|
||||
"""Get job details."""
|
||||
resp = _request("get", f"/v1/queues/{queue}/jobs/{job_id}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "unknown")
|
||||
status_style = {
|
||||
"pending": "yellow",
|
||||
"active": "cyan",
|
||||
"completed": "green",
|
||||
"failed": "red",
|
||||
"delayed": "dim",
|
||||
}.get(status, "white")
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Job ID:[/cyan] {job_id}\n"
|
||||
f"[cyan]Job ID:[/cyan] {data.get('id', job_id)}\n"
|
||||
f"[cyan]Queue:[/cyan] {queue}\n"
|
||||
f"[cyan]Status:[/cyan] Completed\n"
|
||||
f"[cyan]Attempts:[/cyan] 1/3\n"
|
||||
f"[cyan]Created:[/cyan] 2024-01-20 10:30:00\n"
|
||||
f"[cyan]Processed:[/cyan] 2024-01-20 10:30:05\n"
|
||||
f"[cyan]Duration:[/cyan] 245ms",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', '-')}\n"
|
||||
f"[cyan]Status:[/cyan] [{status_style}]{status}[/{status_style}]\n"
|
||||
f"[cyan]Attempts:[/cyan] {data.get('attempts', 0)}/{data.get('max_attempts', 3)}\n"
|
||||
f"[cyan]Created:[/cyan] {str(data.get('created_at', ''))[:19]}\n"
|
||||
f"[cyan]Processed:[/cyan] {str(data.get('processed_at', '-'))[:19]}\n"
|
||||
f"[cyan]Duration:[/cyan] {data.get('duration_ms', '-')}ms\n"
|
||||
f"[cyan]Data:[/cyan] {json.dumps(data.get('data', {}), indent=2, default=str)}",
|
||||
title="Job Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -190,14 +291,20 @@ def queues_get(queue: str, job_id: str):
|
||||
@click.option("--count", "-n", type=int, help="Retry N failed jobs")
|
||||
def queues_retry(queue: str, job_id: str, all_failed: bool, count: int):
|
||||
"""Retry failed jobs."""
|
||||
if not job_id and not all_failed and not count:
|
||||
raise click.ClickException("Specify --job-id, --all-failed, or --count")
|
||||
|
||||
payload = {}
|
||||
if job_id:
|
||||
console.print(f"[green]✓[/green] Job '{job_id}' queued for retry")
|
||||
elif all_failed:
|
||||
console.print(f"[green]✓[/green] All failed jobs in '{queue}' queued for retry")
|
||||
elif count:
|
||||
console.print(f"[green]✓[/green] {count} failed jobs queued for retry")
|
||||
else:
|
||||
console.print("[yellow]Specify --job-id, --all-failed, or --count[/yellow]")
|
||||
payload["job_id"] = job_id
|
||||
if all_failed:
|
||||
payload["all_failed"] = True
|
||||
if count:
|
||||
payload["count"] = count
|
||||
|
||||
resp = _request("post", f"/v1/queues/{queue}/retry", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] {data.get('retried', 0)} job(s) queued for retry")
|
||||
|
||||
|
||||
@queues_group.group()
|
||||
@@ -211,6 +318,10 @@ def dlq():
|
||||
@click.option("--limit", "-n", default=20, help="Max jobs to show")
|
||||
def dlq_list(queue: str, limit: int):
|
||||
"""List jobs in dead letter queue."""
|
||||
resp = _request("get", f"/v1/queues/{queue}/dlq", params={"limit": limit})
|
||||
data = check_response(resp)
|
||||
jobs = data.get("jobs", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Dead Letter Queue: {queue}", box=box.ROUNDED)
|
||||
table.add_column("Job ID", style="cyan")
|
||||
table.add_column("Name", style="white")
|
||||
@@ -218,8 +329,18 @@ def dlq_list(queue: str, limit: int):
|
||||
table.add_column("Attempts", style="yellow")
|
||||
table.add_column("Failed At", style="dim")
|
||||
|
||||
for j in jobs:
|
||||
table.add_row(
|
||||
str(j.get("id", ""))[:16],
|
||||
j.get("name", "-"),
|
||||
str(j.get("error", "-"))[:40],
|
||||
str(j.get("attempts", 0)),
|
||||
str(j.get("failed_at", ""))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No jobs in DLQ[/dim]")
|
||||
if not jobs:
|
||||
console.print("[dim]No jobs in DLQ[/dim]")
|
||||
|
||||
|
||||
@dlq.command(name="retry")
|
||||
@@ -228,12 +349,18 @@ def dlq_list(queue: str, limit: int):
|
||||
@click.option("--all", "-a", "all_jobs", is_flag=True, help="Retry all DLQ jobs")
|
||||
def dlq_retry(queue: str, job_id: str, all_jobs: bool):
|
||||
"""Retry jobs from dead letter queue."""
|
||||
if not job_id and not all_jobs:
|
||||
raise click.ClickException("Specify --job-id or --all")
|
||||
|
||||
payload = {}
|
||||
if job_id:
|
||||
console.print(f"[green]✓[/green] DLQ job '{job_id}' moved back to queue")
|
||||
elif all_jobs:
|
||||
console.print(f"[green]✓[/green] All DLQ jobs moved back to '{queue}'")
|
||||
else:
|
||||
console.print("[yellow]Specify --job-id or --all[/yellow]")
|
||||
payload["job_id"] = job_id
|
||||
if all_jobs:
|
||||
payload["all"] = True
|
||||
|
||||
resp = _request("post", f"/v1/queues/{queue}/dlq/retry", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] {data.get('retried', 0)} DLQ job(s) moved back to queue")
|
||||
|
||||
|
||||
@dlq.command(name="purge")
|
||||
@@ -244,7 +371,10 @@ def dlq_purge(queue: str):
|
||||
|
||||
if not Confirm.ask(f"[red]Purge all DLQ jobs for '{queue}'?[/red]"):
|
||||
return
|
||||
console.print(f"[green]✓[/green] DLQ purged for '{queue}'")
|
||||
|
||||
resp = _request("post", f"/v1/queues/{queue}/dlq/purge")
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Purged {data.get('purged', 0)} DLQ job(s) for '{queue}'")
|
||||
|
||||
|
||||
@queues_group.command(name="drain")
|
||||
@@ -256,7 +386,11 @@ def queues_drain(queue: str, delayed: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Drain all jobs from '{queue}'?[/red]"):
|
||||
return
|
||||
console.print(f"[green]✓[/green] Queue '{queue}' drained")
|
||||
|
||||
payload = {"delayed": delayed}
|
||||
resp = _request("post", f"/v1/queues/{queue}/drain", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Drained {data.get('drained', 0)} job(s) from '{queue}'")
|
||||
if delayed:
|
||||
console.print("[dim]Delayed jobs also removed[/dim]")
|
||||
|
||||
@@ -265,6 +399,8 @@ def queues_drain(queue: str, delayed: bool):
|
||||
@click.argument("queue")
|
||||
def queues_pause(queue: str):
|
||||
"""Pause queue processing."""
|
||||
resp = _request("post", f"/v1/queues/{queue}/pause")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Queue '{queue}' paused")
|
||||
|
||||
|
||||
@@ -272,4 +408,6 @@ def queues_pause(queue: str):
|
||||
@click.argument("queue")
|
||||
def queues_resume(queue: str):
|
||||
"""Resume queue processing."""
|
||||
resp = _request("post", f"/v1/queues/{queue}/resume")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Queue '{queue}' resumed")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Hybrid search with lexical (BM25) and vector (semantic) capabilities.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
SEARCH_URL = os.getenv("HANZO_SEARCH_URL", "https://search.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(SEARCH_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="search")
|
||||
@@ -54,7 +65,15 @@ def search_group():
|
||||
@click.option("--tier", "-t", type=click.Choice(["free", "standard", "dedicated"]), default="standard")
|
||||
def search_create(name: str, mode: str, region: str, tier: str):
|
||||
"""Create a search engine."""
|
||||
payload = {"name": name, "mode": mode, "tier": tier}
|
||||
if region:
|
||||
payload["region"] = region
|
||||
|
||||
resp = _request("post", "/v1/engines", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Search engine '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Mode: {mode}")
|
||||
console.print(f" Tier: {tier}")
|
||||
if region:
|
||||
@@ -64,6 +83,10 @@ def search_create(name: str, mode: str, region: str, tier: str):
|
||||
@search_group.command(name="list")
|
||||
def search_list():
|
||||
"""List search engines."""
|
||||
resp = _request("get", "/v1/engines")
|
||||
data = check_response(resp)
|
||||
engines = data.get("engines", data.get("items", []))
|
||||
|
||||
table = Table(title="Search Engines", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Mode", style="white")
|
||||
@@ -71,24 +94,42 @@ def search_list():
|
||||
table.add_column("Documents", style="yellow")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for e in engines:
|
||||
status = e.get("status", "unknown")
|
||||
style = "green" if status == "running" else "yellow"
|
||||
table.add_row(
|
||||
e.get("name", ""),
|
||||
e.get("mode", "-"),
|
||||
str(e.get("index_count", 0)),
|
||||
str(e.get("document_count", 0)),
|
||||
f"[{style}]● {status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No search engines found. Create one with 'hanzo search create'[/dim]")
|
||||
if not engines:
|
||||
console.print("[dim]No search engines found. Create one with 'hanzo search create'[/dim]")
|
||||
|
||||
|
||||
@search_group.command(name="describe")
|
||||
@click.argument("name")
|
||||
def search_describe(name: str):
|
||||
"""Show search engine details."""
|
||||
resp = _request("get", f"/v1/engines/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "unknown")
|
||||
style = "green" if status == "running" else "yellow"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Mode:[/cyan] Hybrid (lexical + vector)\n"
|
||||
f"[cyan]Status:[/cyan] [green]● Running[/green]\n"
|
||||
f"[cyan]Indexes:[/cyan] 3\n"
|
||||
f"[cyan]Documents:[/cyan] 100,000\n"
|
||||
f"[cyan]Size:[/cyan] 1.2 GB\n"
|
||||
f"[cyan]Queries/day:[/cyan] 50,000\n"
|
||||
f"[cyan]Endpoint:[/cyan] https://search.hanzo.ai/{name}",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Mode:[/cyan] {data.get('mode', '-')}\n"
|
||||
f"[cyan]Status:[/cyan] [{style}]● {status}[/{style}]\n"
|
||||
f"[cyan]Indexes:[/cyan] {data.get('index_count', 0)}\n"
|
||||
f"[cyan]Documents:[/cyan] {data.get('document_count', 0):,}\n"
|
||||
f"[cyan]Size:[/cyan] {data.get('size', '0 B')}\n"
|
||||
f"[cyan]Queries/day:[/cyan] {data.get('queries_per_day', 0):,}\n"
|
||||
f"[cyan]Endpoint:[/cyan] {data.get('endpoint', f'{SEARCH_URL}/{name}')}",
|
||||
title="Search Engine Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -105,6 +146,9 @@ def search_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete search engine '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/engines/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Search engine '{name}' deleted")
|
||||
|
||||
|
||||
@@ -128,6 +172,20 @@ def index():
|
||||
@click.option("--replicas", "-r", default=1, help="Number of replicas")
|
||||
def index_create(name: str, engine: str, mode: str, mapping: str, shards: int, replicas: int):
|
||||
"""Create a search index."""
|
||||
payload = {"name": name, "shards": shards, "replicas": replicas}
|
||||
if mode:
|
||||
payload["mode"] = mode
|
||||
if mapping:
|
||||
from pathlib import Path
|
||||
|
||||
if Path(mapping).exists():
|
||||
payload["mapping"] = json.loads(Path(mapping).read_text())
|
||||
else:
|
||||
payload["mapping"] = json.loads(mapping)
|
||||
|
||||
resp = _request("post", f"/v1/engines/{engine}/indexes", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Index '{name}' created in '{engine}'")
|
||||
console.print(f" Shards: {shards}")
|
||||
console.print(f" Replicas: {replicas}")
|
||||
@@ -137,6 +195,10 @@ def index_create(name: str, engine: str, mode: str, mapping: str, shards: int, r
|
||||
@click.option("--engine", "-e", default="default")
|
||||
def index_list(engine: str):
|
||||
"""List indexes in an engine."""
|
||||
resp = _request("get", f"/v1/engines/{engine}/indexes")
|
||||
data = check_response(resp)
|
||||
items = data.get("indexes", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Indexes in '{engine}'", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Mode", style="white")
|
||||
@@ -144,8 +206,20 @@ def index_list(engine: str):
|
||||
table.add_column("Size", style="yellow")
|
||||
table.add_column("Health", style="dim")
|
||||
|
||||
for idx in items:
|
||||
health = idx.get("health", "unknown")
|
||||
style = "green" if health == "green" else "yellow" if health == "yellow" else "red"
|
||||
table.add_row(
|
||||
idx.get("name", ""),
|
||||
idx.get("mode", "-"),
|
||||
str(idx.get("document_count", 0)),
|
||||
idx.get("size", "0 B"),
|
||||
f"[{style}]● {health}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No indexes found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No indexes found[/dim]")
|
||||
|
||||
|
||||
@index.command(name="describe")
|
||||
@@ -153,15 +227,18 @@ def index_list(engine: str):
|
||||
@click.option("--engine", "-e", default="default")
|
||||
def index_describe(name: str, engine: str):
|
||||
"""Show index details."""
|
||||
resp = _request("get", f"/v1/engines/{engine}/indexes/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Index:[/cyan] {name}\n"
|
||||
f"[cyan]Index:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Engine:[/cyan] {engine}\n"
|
||||
f"[cyan]Mode:[/cyan] Hybrid\n"
|
||||
f"[cyan]Documents:[/cyan] 10,000\n"
|
||||
f"[cyan]Size:[/cyan] 150 MB\n"
|
||||
f"[cyan]Shards:[/cyan] 1\n"
|
||||
f"[cyan]Replicas:[/cyan] 1",
|
||||
f"[cyan]Mode:[/cyan] {data.get('mode', '-')}\n"
|
||||
f"[cyan]Documents:[/cyan] {data.get('document_count', 0):,}\n"
|
||||
f"[cyan]Size:[/cyan] {data.get('size', '0 B')}\n"
|
||||
f"[cyan]Shards:[/cyan] {data.get('shards', 1)}\n"
|
||||
f"[cyan]Replicas:[/cyan] {data.get('replicas', 1)}",
|
||||
title="Index Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -179,6 +256,9 @@ def index_delete(name: str, engine: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete index '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/engines/{engine}/indexes/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Index '{name}' deleted")
|
||||
|
||||
|
||||
@@ -190,10 +270,21 @@ def index_delete(name: str, engine: str, force: bool):
|
||||
def index_mapping(name: str, engine: str, get_mapping: bool, set_mapping: str):
|
||||
"""Get or set index mapping."""
|
||||
if set_mapping:
|
||||
from pathlib import Path
|
||||
|
||||
if Path(set_mapping).exists():
|
||||
mapping_data = json.loads(Path(set_mapping).read_text())
|
||||
else:
|
||||
mapping_data = json.loads(set_mapping)
|
||||
|
||||
resp = _request("put", f"/v1/engines/{engine}/indexes/{name}/mapping", json=mapping_data)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Mapping updated for '{name}'")
|
||||
else:
|
||||
resp = _request("get", f"/v1/engines/{engine}/indexes/{name}/mapping")
|
||||
data = check_response(resp)
|
||||
console.print(f"[cyan]Mapping for '{name}':[/cyan]")
|
||||
console.print("{}")
|
||||
console.print(json.dumps(data.get("mapping", data), indent=2))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -218,9 +309,23 @@ def search_ingest(index: str, source: str, fmt: str, pipeline: str, batch_size:
|
||||
hanzo search ingest -i docs --from storage://mybucket/docs.parquet
|
||||
"""
|
||||
console.print(f"[cyan]Ingesting into '{index}' from '{source}'...[/cyan]")
|
||||
console.print("[green]✓[/green] Ingestion complete")
|
||||
console.print(" Documents: 0")
|
||||
console.print(" Errors: 0")
|
||||
|
||||
payload = {
|
||||
"source": source,
|
||||
"format": fmt,
|
||||
"batch_size": batch_size,
|
||||
}
|
||||
if pipeline:
|
||||
payload["pipeline"] = pipeline
|
||||
|
||||
resp = _request("post", f"/v1/engines/{engine}/indexes/{index}/ingest", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Ingestion complete")
|
||||
console.print(f" Documents: {data.get('ingested', 0):,}")
|
||||
console.print(f" Errors: {data.get('errors', 0)}")
|
||||
if data.get("duration"):
|
||||
console.print(f" Duration: {data['duration']}")
|
||||
|
||||
|
||||
@search_group.command(name="query")
|
||||
@@ -241,8 +346,41 @@ def search_query(index: str, q: str, filter: str, topk: int, mode: str, vector_f
|
||||
hanzo search query -i docs -q "machine learning" --mode vector
|
||||
hanzo search query -i users -q "john" -f "status=active"
|
||||
"""
|
||||
console.print(f"[cyan]Results for '{q}' in '{index}':[/cyan]")
|
||||
console.print("[dim]No results found[/dim]")
|
||||
payload = {"query": q, "top_k": topk}
|
||||
if filter:
|
||||
payload["filter"] = filter
|
||||
if mode:
|
||||
payload["mode"] = mode
|
||||
if vector_field:
|
||||
payload["vector_field"] = vector_field
|
||||
|
||||
resp = _request("post", f"/v1/engines/{engine}/indexes/{index}/query", json=payload)
|
||||
data = check_response(resp)
|
||||
hits = data.get("hits", data.get("results", []))
|
||||
|
||||
if as_json:
|
||||
click.echo(json.dumps(data, indent=2, default=str))
|
||||
return
|
||||
|
||||
table = Table(title=f"Results for '{q}' in '{index}'", box=box.ROUNDED)
|
||||
table.add_column("#", style="dim")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Score", style="green")
|
||||
table.add_column("Source", style="white")
|
||||
|
||||
for i, hit in enumerate(hits, 1):
|
||||
source = hit.get("_source", hit.get("document", {}))
|
||||
source_str = json.dumps(source, default=str)[:80] if source else "-"
|
||||
table.add_row(
|
||||
str(i),
|
||||
str(hit.get("_id", hit.get("id", "")))[:24],
|
||||
f"{hit.get('_score', hit.get('score', 0)):.4f}",
|
||||
source_str,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
total = data.get("total", len(hits))
|
||||
console.print(f"[dim]{total} total hit(s), showing top {min(topk, len(hits))}[/dim]")
|
||||
|
||||
|
||||
@search_group.command(name="reindex")
|
||||
@@ -254,7 +392,20 @@ def search_query(index: str, q: str, filter: str, topk: int, mode: str, vector_f
|
||||
def search_reindex(source_idx: str, dest_idx: str, query: str, pipeline: str, engine: str):
|
||||
"""Reindex documents."""
|
||||
console.print(f"[cyan]Reindexing from '{source_idx}' to '{dest_idx}'...[/cyan]")
|
||||
console.print("[green]✓[/green] Reindex complete")
|
||||
|
||||
payload = {"source": source_idx, "destination": dest_idx}
|
||||
if query:
|
||||
payload["query"] = query
|
||||
if pipeline:
|
||||
payload["pipeline"] = pipeline
|
||||
|
||||
resp = _request("post", f"/v1/engines/{engine}/reindex", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Reindex complete")
|
||||
console.print(f" Documents: {data.get('reindexed', 0):,}")
|
||||
if data.get("duration"):
|
||||
console.print(f" Duration: {data['duration']}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -274,20 +425,45 @@ def pipeline():
|
||||
@click.option("--config", "-c", help="Pipeline config (JSON or file)")
|
||||
def pipeline_create(name: str, engine: str, config: str):
|
||||
"""Create an ingest pipeline."""
|
||||
payload = {"name": name}
|
||||
if config:
|
||||
from pathlib import Path
|
||||
|
||||
if Path(config).exists():
|
||||
payload["config"] = json.loads(Path(config).read_text())
|
||||
else:
|
||||
payload["config"] = json.loads(config)
|
||||
|
||||
resp = _request("post", f"/v1/engines/{engine}/pipelines", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Pipeline '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
|
||||
|
||||
@pipeline.command(name="list")
|
||||
@click.option("--engine", "-e", default="default")
|
||||
def pipeline_list(engine: str):
|
||||
"""List ingest pipelines."""
|
||||
resp = _request("get", f"/v1/engines/{engine}/pipelines")
|
||||
data = check_response(resp)
|
||||
items = data.get("pipelines", data.get("items", []))
|
||||
|
||||
table = Table(title="Ingest Pipelines", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Processors", style="white")
|
||||
table.add_column("Description", style="dim")
|
||||
|
||||
for p in items:
|
||||
processors = p.get("processors", [])
|
||||
table.add_row(
|
||||
p.get("name", ""),
|
||||
str(len(processors)),
|
||||
p.get("description", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No pipelines found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No pipelines found[/dim]")
|
||||
|
||||
|
||||
@pipeline.command(name="describe")
|
||||
@@ -295,7 +471,19 @@ def pipeline_list(engine: str):
|
||||
@click.option("--engine", "-e", default="default")
|
||||
def pipeline_describe(name: str, engine: str):
|
||||
"""Show pipeline details."""
|
||||
console.print(f"[dim]Pipeline '{name}' not found[/dim]")
|
||||
resp = _request("get", f"/v1/engines/{engine}/pipelines/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Description:[/cyan] {data.get('description', '-')}\n"
|
||||
f"[cyan]Processors:[/cyan] {len(data.get('processors', []))}\n"
|
||||
f"[cyan]Config:[/cyan]\n{json.dumps(data.get('config', {}), indent=2)}",
|
||||
title="Pipeline Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pipeline.command(name="delete")
|
||||
@@ -303,6 +491,8 @@ def pipeline_describe(name: str, engine: str):
|
||||
@click.option("--engine", "-e", default="default")
|
||||
def pipeline_delete(name: str, engine: str):
|
||||
"""Delete a pipeline."""
|
||||
resp = _request("delete", f"/v1/engines/{engine}/pipelines/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Pipeline '{name}' deleted")
|
||||
|
||||
|
||||
@@ -312,8 +502,16 @@ def pipeline_delete(name: str, engine: str):
|
||||
@click.option("--engine", "-e", default="default")
|
||||
def pipeline_test(name: str, doc: str, engine: str):
|
||||
"""Test a pipeline with sample document."""
|
||||
console.print(f"[cyan]Testing pipeline '{name}'...[/cyan]")
|
||||
console.print("[green]✓[/green] Pipeline test passed")
|
||||
payload = {}
|
||||
if doc:
|
||||
payload["document"] = json.loads(doc)
|
||||
|
||||
resp = _request("post", f"/v1/engines/{engine}/pipelines/{name}/test", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Pipeline test passed")
|
||||
if data.get("output"):
|
||||
console.print(f" Output: {json.dumps(data['output'], indent=2, default=str)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -325,14 +523,17 @@ def pipeline_test(name: str, doc: str, engine: str):
|
||||
@click.option("--engine", "-e", default="default")
|
||||
def search_stats(engine: str):
|
||||
"""Show search engine statistics."""
|
||||
resp = _request("get", f"/v1/engines/{engine}/stats")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Engine:[/cyan] {engine}\n"
|
||||
f"[cyan]Indexes:[/cyan] 0\n"
|
||||
f"[cyan]Documents:[/cyan] 0\n"
|
||||
f"[cyan]Size:[/cyan] 0 B\n"
|
||||
f"[cyan]Queries/day:[/cyan] 0\n"
|
||||
f"[cyan]Avg latency:[/cyan] 0ms",
|
||||
f"[cyan]Engine:[/cyan] {data.get('name', engine)}\n"
|
||||
f"[cyan]Indexes:[/cyan] {data.get('index_count', 0)}\n"
|
||||
f"[cyan]Documents:[/cyan] {data.get('document_count', 0):,}\n"
|
||||
f"[cyan]Size:[/cyan] {data.get('size', '0 B')}\n"
|
||||
f"[cyan]Queries/day:[/cyan] {data.get('queries_per_day', 0):,}\n"
|
||||
f"[cyan]Avg latency:[/cyan] {data.get('avg_latency_ms', 0)}ms",
|
||||
title="Search Statistics",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -345,5 +546,14 @@ def search_stats(engine: str):
|
||||
@click.option("--output", "-o", help="Output location")
|
||||
def search_backup(engine: str, index: str, output: str):
|
||||
"""Backup search data."""
|
||||
payload = {"engine": engine}
|
||||
if index:
|
||||
payload["index"] = index
|
||||
if output:
|
||||
payload["output"] = output
|
||||
|
||||
resp = _request("post", f"/v1/engines/{engine}/backups", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Backup created")
|
||||
console.print(" Backup ID: bak_abc123")
|
||||
console.print(f" Backup ID: {data.get('id', '-')}")
|
||||
console.print(f" Size: {data.get('size', '-')}")
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
"""Hanzo Secrets - Secret management CLI.
|
||||
|
||||
Secure secret storage with versioning and rotation.
|
||||
Secure secret storage with versioning and rotation via Hanzo KMS.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
KMS_URL = os.getenv("HANZO_KMS_URL", "https://kms.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(KMS_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="secrets")
|
||||
@@ -57,17 +69,27 @@ def secrets_set(name: str, value: str, file: str, env: str, description: str):
|
||||
echo "secret" | hanzo secrets set DB_PASSWORD
|
||||
hanzo secrets set CERT --file ./cert.pem
|
||||
"""
|
||||
if not value and not file:
|
||||
# Read from stdin
|
||||
import sys
|
||||
if file:
|
||||
from pathlib import Path
|
||||
|
||||
value = Path(file).read_text().strip()
|
||||
elif not value:
|
||||
if not sys.stdin.isatty():
|
||||
value = sys.stdin.read().strip()
|
||||
else:
|
||||
value = click.prompt("Secret value", hide_input=True)
|
||||
|
||||
payload = {"key": name, "value": value}
|
||||
if env:
|
||||
payload["environment"] = env
|
||||
if description:
|
||||
payload["description"] = description
|
||||
|
||||
resp = _request("post", "/v1/secrets", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Secret '{name}' set")
|
||||
console.print(f" Version: 1")
|
||||
console.print(f" Version: {data.get('version', 1)}")
|
||||
if env:
|
||||
console.print(f" Environment: {env}")
|
||||
|
||||
@@ -79,7 +101,29 @@ def secrets_set(name: str, value: str, file: str, env: str, description: str):
|
||||
@click.option("--plain", is_flag=True, help="Output value only (no formatting)")
|
||||
def secrets_get(name: str, env: str, version: str, plain: bool):
|
||||
"""Get a secret value."""
|
||||
console.print(f"[yellow]Warning:[/yellow] Secret '{name}' not found")
|
||||
params = {}
|
||||
if env:
|
||||
params["environment"] = env
|
||||
if version:
|
||||
params["version"] = version
|
||||
|
||||
resp = _request("get", f"/v1/secrets/{name}", params=params)
|
||||
data = check_response(resp)
|
||||
|
||||
if plain:
|
||||
click.echo(data.get("value", ""))
|
||||
else:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Value:[/cyan] {data.get('value', '')}\n"
|
||||
f"[cyan]Version:[/cyan] {data.get('version', '-')}\n"
|
||||
f"[cyan]Environment:[/cyan] {data.get('environment', 'default')}\n"
|
||||
f"[cyan]Updated:[/cyan] {data.get('updated_at', '-')}",
|
||||
title="Secret",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@secrets_group.command(name="list")
|
||||
@@ -87,14 +131,33 @@ def secrets_get(name: str, env: str, version: str, plain: bool):
|
||||
@click.option("--prefix", "-p", help="Filter by prefix")
|
||||
def secrets_list(env: str, prefix: str):
|
||||
"""List all secrets (names only, not values)."""
|
||||
params = {}
|
||||
if env:
|
||||
params["environment"] = env
|
||||
if prefix:
|
||||
params["prefix"] = prefix
|
||||
|
||||
resp = _request("get", "/v1/secrets", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("secrets", data.get("items", []))
|
||||
|
||||
table = Table(title="Secrets", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Environment", style="white")
|
||||
table.add_column("Version", style="green")
|
||||
table.add_column("Updated", style="dim")
|
||||
|
||||
for s in items:
|
||||
table.add_row(
|
||||
s.get("key", s.get("name", "")),
|
||||
s.get("environment", "default"),
|
||||
str(s.get("version", "-")),
|
||||
str(s.get("updated_at", "-"))[:19],
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No secrets found. Create one with 'hanzo secrets set'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No secrets found. Create one with 'hanzo secrets set'[/dim]")
|
||||
|
||||
|
||||
@secrets_group.command(name="unset")
|
||||
@@ -108,6 +171,13 @@ def secrets_unset(name: str, env: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete secret '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
params = {}
|
||||
if env:
|
||||
params["environment"] = env
|
||||
|
||||
resp = _request("delete", f"/v1/secrets/{name}", params=params)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Secret '{name}' deleted")
|
||||
|
||||
|
||||
@@ -116,9 +186,16 @@ def secrets_unset(name: str, env: str, force: bool):
|
||||
@click.option("--env", "-e", help="Environment")
|
||||
def secrets_rotate(name: str, env: str):
|
||||
"""Rotate a secret (generate new value)."""
|
||||
payload = {}
|
||||
if env:
|
||||
payload["environment"] = env
|
||||
|
||||
resp = _request("post", f"/v1/secrets/{name}/rotate", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Secret '{name}' rotated")
|
||||
console.print(" New version: 2")
|
||||
console.print(" Previous version expires in 24h")
|
||||
console.print(f" New version: {data.get('version', '-')}")
|
||||
console.print(f" Previous version expires: {data.get('previous_expires', '24h')}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -131,14 +208,33 @@ def secrets_rotate(name: str, env: str):
|
||||
@click.option("--env", "-e", help="Environment")
|
||||
def secrets_versions(name: str, env: str):
|
||||
"""List secret versions."""
|
||||
params = {}
|
||||
if env:
|
||||
params["environment"] = env
|
||||
|
||||
resp = _request("get", f"/v1/secrets/{name}/versions", params=params)
|
||||
data = check_response(resp)
|
||||
versions = data.get("versions", [])
|
||||
|
||||
table = Table(title=f"Versions: {name}", box=box.ROUNDED)
|
||||
table.add_column("Version", style="cyan")
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Created", style="dim")
|
||||
table.add_column("Created By", style="dim")
|
||||
|
||||
for v in versions:
|
||||
status = "● Active" if v.get("active") else "○ Inactive"
|
||||
style = "green" if v.get("active") else "dim"
|
||||
table.add_row(
|
||||
str(v.get("version", "")),
|
||||
f"[{style}]{status}[/{style}]",
|
||||
str(v.get("created_at", ""))[:19],
|
||||
v.get("created_by", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"[dim]No versions found for '{name}'[/dim]")
|
||||
if not versions:
|
||||
console.print(f"[dim]No versions found for '{name}'[/dim]")
|
||||
|
||||
|
||||
@secrets_group.command(name="rollback")
|
||||
@@ -147,6 +243,12 @@ def secrets_versions(name: str, env: str):
|
||||
@click.option("--env", "-e", help="Environment")
|
||||
def secrets_rollback(name: str, version: str, env: str):
|
||||
"""Rollback to a previous secret version."""
|
||||
payload = {"version": int(version)}
|
||||
if env:
|
||||
payload["environment"] = env
|
||||
|
||||
resp = _request("post", f"/v1/secrets/{name}/rollback", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Rolled back '{name}' to version {version}")
|
||||
|
||||
|
||||
@@ -161,6 +263,8 @@ def secrets_rollback(name: str, version: str, env: str):
|
||||
@click.option("--role", "-r", default="read", type=click.Choice(["read", "write", "admin"]))
|
||||
def secrets_grant(name: str, to: str, role: str):
|
||||
"""Grant access to a secret."""
|
||||
resp = _request("post", f"/v1/secrets/{name}/access", json={"principal": to, "role": role})
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Granted {role} access to '{name}' for {to}")
|
||||
|
||||
|
||||
@@ -169,6 +273,8 @@ def secrets_grant(name: str, to: str, role: str):
|
||||
@click.option("--from", "from_", required=True, help="Service or user to revoke")
|
||||
def secrets_revoke(name: str, from_: str):
|
||||
"""Revoke access to a secret."""
|
||||
resp = _request("delete", f"/v1/secrets/{name}/access/{from_}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Revoked access to '{name}' from {from_}")
|
||||
|
||||
|
||||
@@ -177,6 +283,11 @@ def secrets_revoke(name: str, from_: str):
|
||||
@click.option("--limit", "-n", default=50, help="Number of entries")
|
||||
def secrets_audit(name: str, limit: int):
|
||||
"""View secret access logs."""
|
||||
path = f"/v1/secrets/{name}/audit" if name else "/v1/secrets/audit"
|
||||
resp = _request("get", path, params={"limit": limit})
|
||||
data = check_response(resp)
|
||||
entries = data.get("entries", data.get("logs", []))
|
||||
|
||||
table = Table(title="Secret Access Log", box=box.ROUNDED)
|
||||
table.add_column("Time", style="dim")
|
||||
table.add_column("Secret", style="cyan")
|
||||
@@ -184,5 +295,15 @@ def secrets_audit(name: str, limit: int):
|
||||
table.add_column("Actor", style="green")
|
||||
table.add_column("IP", style="dim")
|
||||
|
||||
for e in entries:
|
||||
table.add_row(
|
||||
str(e.get("timestamp", ""))[:19],
|
||||
e.get("secret", e.get("key", "-")),
|
||||
e.get("action", "-"),
|
||||
e.get("actor", e.get("principal", "-")),
|
||||
e.get("ip", e.get("source_ip", "-")),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No access logs found[/dim]")
|
||||
if not entries:
|
||||
console.print("[dim]No access logs found[/dim]")
|
||||
|
||||
@@ -4,12 +4,23 @@ Developer-facing task graphs, schedules, triggers, and runbooks.
|
||||
Built on top of jobs for execution substrate.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
TASKS_URL = os.getenv("HANZO_TASKS_URL", "https://tasks.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(TASKS_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="tasks")
|
||||
@@ -68,7 +79,25 @@ def tasks_create(name: str, image: str, cmd: str, function: str, env: tuple, tim
|
||||
hanzo tasks create backup --cmd "pg_dump..." --retries 5
|
||||
hanzo tasks create notify --function notifications.send
|
||||
"""
|
||||
body = {"name": name, "timeout": timeout, "max_retries": retries}
|
||||
if image:
|
||||
body["image"] = image
|
||||
if cmd:
|
||||
body["command"] = cmd
|
||||
if function:
|
||||
body["function"] = function
|
||||
if env:
|
||||
env_dict = {}
|
||||
for e in env:
|
||||
k, v = e.split("=", 1)
|
||||
env_dict[k] = v
|
||||
body["env"] = env_dict
|
||||
|
||||
resp = _request("post", "/v1/tasks", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Task '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
if image:
|
||||
console.print(f" Image: {image}")
|
||||
elif cmd:
|
||||
@@ -83,6 +112,14 @@ def tasks_create(name: str, image: str, cmd: str, function: str, env: tuple, tim
|
||||
@click.option("--status", "-s", type=click.Choice(["active", "disabled", "all"]), default="all")
|
||||
def tasks_list(status: str):
|
||||
"""List all tasks."""
|
||||
params = {}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
|
||||
resp = _request("get", "/v1/tasks", params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("tasks", data.get("items", []))
|
||||
|
||||
table = Table(title="Tasks", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Type", style="white")
|
||||
@@ -91,25 +128,46 @@ def tasks_list(status: str):
|
||||
table.add_column("Last Run", style="dim")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for t in items:
|
||||
t_status = t.get("status", "active")
|
||||
style = "green" if t_status == "active" else "yellow"
|
||||
task_type = "image" if t.get("image") else "function" if t.get("function") else "command"
|
||||
table.add_row(
|
||||
t.get("name", ""),
|
||||
task_type,
|
||||
t.get("schedule", "-"),
|
||||
str(t.get("trigger_count", 0)),
|
||||
str(t.get("last_run_at", "-"))[:19],
|
||||
f"[{style}]{t_status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No tasks found. Create one with 'hanzo tasks create'[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No tasks found. Create one with 'hanzo tasks create'[/dim]")
|
||||
|
||||
|
||||
@tasks_group.command(name="describe")
|
||||
@click.argument("name")
|
||||
def tasks_describe(name: str):
|
||||
"""Show task details."""
|
||||
resp = _request("get", f"/v1/tasks/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "active")
|
||||
status_style = "green" if status == "active" else "yellow"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Type:[/cyan] image\n"
|
||||
f"[cyan]Image:[/cyan] my-etl:v1\n"
|
||||
f"[cyan]Timeout:[/cyan] 2h\n"
|
||||
f"[cyan]Retries:[/cyan] 3\n"
|
||||
f"[cyan]Schedule:[/cyan] 0 2 * * * (daily 2am)\n"
|
||||
f"[cyan]Triggers:[/cyan] queue:incoming-data\n"
|
||||
f"[cyan]Last run:[/cyan] 2024-01-20 02:00:00 (success)\n"
|
||||
f"[cyan]Next run:[/cyan] 2024-01-21 02:00:00",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Type:[/cyan] {'image' if data.get('image') else 'function' if data.get('function') else 'command'}\n"
|
||||
f"[cyan]Image:[/cyan] {data.get('image', '-')}\n"
|
||||
f"[cyan]Timeout:[/cyan] {data.get('timeout', '-')}\n"
|
||||
f"[cyan]Retries:[/cyan] {data.get('max_retries', 3)}\n"
|
||||
f"[cyan]Schedule:[/cyan] {data.get('schedule', '-')}\n"
|
||||
f"[cyan]Triggers:[/cyan] {data.get('trigger_count', 0)}\n"
|
||||
f"[cyan]Status:[/cyan] [{status_style}]{status}[/{status_style}]\n"
|
||||
f"[cyan]Last run:[/cyan] {str(data.get('last_run_at', '-'))[:19]}\n"
|
||||
f"[cyan]Next run:[/cyan] {str(data.get('next_run_at', '-'))[:19]}",
|
||||
title="Task Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -126,6 +184,9 @@ def tasks_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete task '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/tasks/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Task '{name}' deleted")
|
||||
|
||||
|
||||
@@ -136,17 +197,40 @@ def tasks_delete(name: str, force: bool):
|
||||
@click.option("--env", "-e", multiple=True, help="Override env vars")
|
||||
def tasks_run(name: str, input_data: str, wait: bool, env: tuple):
|
||||
"""Run a task manually."""
|
||||
run_id = "run_abc123"
|
||||
body = {}
|
||||
if input_data:
|
||||
body["input"] = json.loads(input_data)
|
||||
if env:
|
||||
env_dict = {}
|
||||
for e in env:
|
||||
k, v = e.split("=", 1)
|
||||
env_dict[k] = v
|
||||
body["env"] = env_dict
|
||||
|
||||
resp = _request("post", f"/v1/tasks/{name}/run", json=body)
|
||||
data = check_response(resp)
|
||||
|
||||
run_id = data.get("id", data.get("run_id", "-"))
|
||||
console.print(f"[green]✓[/green] Task '{name}' started")
|
||||
console.print(f" Run ID: {run_id}")
|
||||
|
||||
if wait:
|
||||
console.print("[dim]Waiting for completion...[/dim]")
|
||||
resp = _request("get", f"/v1/tasks/{name}/runs/{run_id}/wait", timeout=300)
|
||||
result = check_response(resp)
|
||||
run_status = result.get("status", "unknown")
|
||||
style = "green" if run_status == "success" else "red"
|
||||
console.print(f" Status: [{style}]{run_status}[/{style}]")
|
||||
if result.get("duration_ms"):
|
||||
console.print(f" Duration: {result['duration_ms']}ms")
|
||||
|
||||
|
||||
@tasks_group.command(name="enable")
|
||||
@click.argument("name")
|
||||
def tasks_enable(name: str):
|
||||
"""Enable a task."""
|
||||
resp = _request("post", f"/v1/tasks/{name}/enable")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Task '{name}' enabled")
|
||||
|
||||
|
||||
@@ -154,6 +238,8 @@ def tasks_enable(name: str):
|
||||
@click.argument("name")
|
||||
def tasks_disable(name: str):
|
||||
"""Disable a task."""
|
||||
resp = _request("post", f"/v1/tasks/{name}/disable")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Task '{name}' disabled")
|
||||
|
||||
|
||||
@@ -175,6 +261,18 @@ def schedule():
|
||||
@click.option("--timezone", "-tz", default="UTC", help="Timezone")
|
||||
def schedule_set(task: str, cron: str, every: str, timezone: str):
|
||||
"""Set schedule for a task."""
|
||||
if not cron and not every:
|
||||
raise click.ClickException("Specify --cron or --every")
|
||||
|
||||
body = {"timezone": timezone}
|
||||
if cron:
|
||||
body["cron"] = cron
|
||||
if every:
|
||||
body["interval"] = every
|
||||
|
||||
resp = _request("put", f"/v1/tasks/{task}/schedule", json=body)
|
||||
check_response(resp)
|
||||
|
||||
if cron:
|
||||
console.print(f"[green]✓[/green] Scheduled '{task}' with cron: {cron}")
|
||||
elif every:
|
||||
@@ -185,6 +283,10 @@ def schedule_set(task: str, cron: str, every: str, timezone: str):
|
||||
@schedule.command(name="list")
|
||||
def schedule_list():
|
||||
"""List all schedules."""
|
||||
resp = _request("get", "/v1/schedules")
|
||||
data = check_response(resp)
|
||||
items = data.get("schedules", data.get("items", []))
|
||||
|
||||
table = Table(title="Schedules", box=box.ROUNDED)
|
||||
table.add_column("Task", style="cyan")
|
||||
table.add_column("Schedule", style="white")
|
||||
@@ -192,14 +294,28 @@ def schedule_list():
|
||||
table.add_column("Next Run", style="yellow")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for s in items:
|
||||
s_status = s.get("status", "active")
|
||||
style = "green" if s_status == "active" else "yellow"
|
||||
table.add_row(
|
||||
s.get("task", ""),
|
||||
s.get("cron", s.get("interval", "-")),
|
||||
s.get("timezone", "UTC"),
|
||||
str(s.get("next_run_at", "-"))[:19],
|
||||
f"[{style}]{s_status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No schedules found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No schedules found[/dim]")
|
||||
|
||||
|
||||
@schedule.command(name="rm")
|
||||
@click.argument("task")
|
||||
def schedule_rm(task: str):
|
||||
"""Remove schedule from a task."""
|
||||
resp = _request("delete", f"/v1/tasks/{task}/schedule")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Removed schedule from '{task}'")
|
||||
|
||||
|
||||
@@ -207,6 +323,8 @@ def schedule_rm(task: str):
|
||||
@click.argument("task")
|
||||
def schedule_pause(task: str):
|
||||
"""Pause a schedule."""
|
||||
resp = _request("post", f"/v1/tasks/{task}/schedule/pause")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Paused schedule for '{task}'")
|
||||
|
||||
|
||||
@@ -214,6 +332,8 @@ def schedule_pause(task: str):
|
||||
@click.argument("task")
|
||||
def schedule_resume(task: str):
|
||||
"""Resume a schedule."""
|
||||
resp = _request("post", f"/v1/tasks/{task}/schedule/resume")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Resumed schedule for '{task}'")
|
||||
|
||||
|
||||
@@ -234,6 +354,15 @@ def runs():
|
||||
@click.option("--limit", "-n", default=20, help="Max results")
|
||||
def runs_list(task: str, status: str, limit: int):
|
||||
"""List task runs."""
|
||||
params = {"limit": limit}
|
||||
if status != "all":
|
||||
params["status"] = status
|
||||
|
||||
path = f"/v1/tasks/{task}/runs" if task else "/v1/runs"
|
||||
resp = _request("get", path, params=params)
|
||||
data = check_response(resp)
|
||||
items = data.get("runs", data.get("items", []))
|
||||
|
||||
title = f"Runs: {task}" if task else "All Runs"
|
||||
table = Table(title=title, box=box.ROUNDED)
|
||||
table.add_column("Run ID", style="cyan")
|
||||
@@ -243,8 +372,26 @@ def runs_list(task: str, status: str, limit: int):
|
||||
table.add_column("Duration", style="dim")
|
||||
table.add_column("Trigger", style="dim")
|
||||
|
||||
for r in items:
|
||||
r_status = r.get("status", "unknown")
|
||||
status_style = {
|
||||
"running": "cyan",
|
||||
"success": "green",
|
||||
"failed": "red",
|
||||
}.get(r_status, "white")
|
||||
|
||||
table.add_row(
|
||||
str(r.get("id", ""))[:16],
|
||||
r.get("task", "-"),
|
||||
f"[{status_style}]{r_status}[/{status_style}]",
|
||||
str(r.get("started_at", ""))[:19],
|
||||
f"{r.get('duration_ms', '-')}ms" if r.get("duration_ms") else "-",
|
||||
r.get("trigger", "-"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No runs found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No runs found[/dim]")
|
||||
|
||||
|
||||
@runs.command(name="logs")
|
||||
@@ -253,14 +400,35 @@ def runs_list(task: str, status: str, limit: int):
|
||||
@click.option("--tail", "-n", default=100, help="Number of lines")
|
||||
def runs_logs(run_id: str, follow: bool, tail: int):
|
||||
"""View run logs."""
|
||||
params = {"tail": tail}
|
||||
if follow:
|
||||
params["follow"] = "true"
|
||||
|
||||
resp = _request("get", f"/v1/runs/{run_id}/logs", params=params)
|
||||
data = check_response(resp)
|
||||
lines = data.get("logs", data.get("lines", []))
|
||||
|
||||
console.print(f"[cyan]Logs for run {run_id}:[/cyan]")
|
||||
console.print("[dim]No logs available[/dim]")
|
||||
for line in lines:
|
||||
if isinstance(line, dict):
|
||||
ts = str(line.get("timestamp", ""))[:19]
|
||||
level = line.get("level", "info")
|
||||
msg = line.get("message", "")
|
||||
style = "red" if level == "error" else "yellow" if level == "warn" else "dim"
|
||||
console.print(f"[dim]{ts}[/dim] [{style}]{level}[/{style}] {msg}")
|
||||
else:
|
||||
console.print(str(line))
|
||||
|
||||
if not lines:
|
||||
console.print("[dim]No logs available[/dim]")
|
||||
|
||||
|
||||
@runs.command(name="cancel")
|
||||
@click.argument("run_id")
|
||||
def runs_cancel(run_id: str):
|
||||
"""Cancel a running task."""
|
||||
resp = _request("post", f"/v1/runs/{run_id}/cancel")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Run '{run_id}' cancelled")
|
||||
|
||||
|
||||
@@ -268,7 +436,9 @@ def runs_cancel(run_id: str):
|
||||
@click.argument("run_id")
|
||||
def runs_retry(run_id: str):
|
||||
"""Retry a failed run."""
|
||||
new_run_id = "run_xyz789"
|
||||
resp = _request("post", f"/v1/runs/{run_id}/retry")
|
||||
data = check_response(resp)
|
||||
new_run_id = data.get("id", data.get("run_id", "-"))
|
||||
console.print(f"[green]✓[/green] Run '{run_id}' retried")
|
||||
console.print(f" New Run ID: {new_run_id}")
|
||||
|
||||
@@ -305,6 +475,13 @@ def triggers_add(task: str, trigger_type: str, source: str, filter: str):
|
||||
hanzo tasks triggers add webhook --on http --source /api/trigger
|
||||
hanzo tasks triggers add sync --on event --source user.signup
|
||||
"""
|
||||
body = {"type": trigger_type, "source": source}
|
||||
if filter:
|
||||
body["filter"] = filter
|
||||
|
||||
resp = _request("post", f"/v1/tasks/{task}/triggers", json=body)
|
||||
check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Added {trigger_type} trigger to '{task}'")
|
||||
console.print(f" Source: {source}")
|
||||
if filter:
|
||||
@@ -315,6 +492,11 @@ def triggers_add(task: str, trigger_type: str, source: str, filter: str):
|
||||
@click.argument("task", required=False)
|
||||
def triggers_list(task: str):
|
||||
"""List triggers."""
|
||||
path = f"/v1/tasks/{task}/triggers" if task else "/v1/triggers"
|
||||
resp = _request("get", path)
|
||||
data = check_response(resp)
|
||||
items = data.get("triggers", data.get("items", []))
|
||||
|
||||
table = Table(title="Triggers", box=box.ROUNDED)
|
||||
table.add_column("Task", style="cyan")
|
||||
table.add_column("Type", style="white")
|
||||
@@ -322,8 +504,20 @@ def triggers_list(task: str):
|
||||
table.add_column("Filter", style="dim")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for t in items:
|
||||
t_status = t.get("status", "active")
|
||||
style = "green" if t_status == "active" else "yellow"
|
||||
table.add_row(
|
||||
t.get("task", "-"),
|
||||
t.get("type", "-"),
|
||||
t.get("source", "-"),
|
||||
t.get("filter", "-"),
|
||||
f"[{style}]{t_status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No triggers found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No triggers found[/dim]")
|
||||
|
||||
|
||||
@triggers.command(name="rm")
|
||||
@@ -332,12 +526,17 @@ def triggers_list(task: str):
|
||||
@click.option("--all", "remove_all", is_flag=True, help="Remove all triggers")
|
||||
def triggers_rm(task: str, source: str, remove_all: bool):
|
||||
"""Remove triggers from a task."""
|
||||
if not source and not remove_all:
|
||||
raise click.ClickException("Specify --source or --all")
|
||||
|
||||
if remove_all:
|
||||
resp = _request("delete", f"/v1/tasks/{task}/triggers")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Removed all triggers from '{task}'")
|
||||
elif source:
|
||||
resp = _request("delete", f"/v1/tasks/{task}/triggers/{source}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Removed trigger '{source}' from '{task}'")
|
||||
else:
|
||||
console.print("[yellow]Specify --source or --all[/yellow]")
|
||||
|
||||
|
||||
@triggers.command(name="pause")
|
||||
@@ -345,6 +544,9 @@ def triggers_rm(task: str, source: str, remove_all: bool):
|
||||
@click.option("--source", "-s", help="Specific trigger")
|
||||
def triggers_pause(task: str, source: str):
|
||||
"""Pause triggers."""
|
||||
path = f"/v1/tasks/{task}/triggers/{source}/pause" if source else f"/v1/tasks/{task}/triggers/pause"
|
||||
resp = _request("post", path)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Paused trigger(s) for '{task}'")
|
||||
|
||||
|
||||
@@ -353,4 +555,7 @@ def triggers_pause(task: str, source: str):
|
||||
@click.option("--source", "-s", help="Specific trigger")
|
||||
def triggers_resume(task: str, source: str):
|
||||
"""Resume triggers."""
|
||||
path = f"/v1/tasks/{task}/triggers/{source}/resume" if source else f"/v1/tasks/{task}/triggers/resume"
|
||||
resp = _request("post", path)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Resumed trigger(s) for '{task}'")
|
||||
|
||||
@@ -3,12 +3,23 @@
|
||||
Purpose-built vector database for AI/ML embeddings and similarity search.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from rich import box
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from ..utils.output import console
|
||||
from .base import service_request, check_response
|
||||
|
||||
VECTOR_URL = os.getenv("HANZO_VECTOR_URL", "https://vector.hanzo.ai")
|
||||
|
||||
|
||||
def _request(method: str, path: str, **kwargs) -> httpx.Response:
|
||||
return service_request(VECTOR_URL, method, path, **kwargs)
|
||||
|
||||
|
||||
@click.group(name="vector")
|
||||
@@ -50,7 +61,15 @@ def vector_group():
|
||||
@click.option("--metric", "-m", type=click.Choice(["cosine", "euclidean", "dotproduct"]), default="cosine")
|
||||
def vector_create(name: str, region: str, tier: str, metric: str):
|
||||
"""Create a vector database."""
|
||||
payload = {"name": name, "tier": tier, "default_metric": metric}
|
||||
if region:
|
||||
payload["region"] = region
|
||||
|
||||
resp = _request("post", "/v1/databases", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Vector database '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Tier: {tier}")
|
||||
console.print(f" Default metric: {metric}")
|
||||
if region:
|
||||
@@ -60,6 +79,10 @@ def vector_create(name: str, region: str, tier: str, metric: str):
|
||||
@vector_group.command(name="list")
|
||||
def vector_list():
|
||||
"""List vector databases."""
|
||||
resp = _request("get", "/v1/databases")
|
||||
data = check_response(resp)
|
||||
dbs = data.get("databases", data.get("items", []))
|
||||
|
||||
table = Table(title="Vector Databases", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Collections", style="green")
|
||||
@@ -67,23 +90,42 @@ def vector_list():
|
||||
table.add_column("Dimensions", style="white")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
for db in dbs:
|
||||
status = db.get("status", "unknown")
|
||||
style = "green" if status == "running" else "yellow"
|
||||
table.add_row(
|
||||
db.get("name", ""),
|
||||
str(db.get("collection_count", 0)),
|
||||
str(db.get("vector_count", 0)),
|
||||
str(db.get("dimensions", "-")),
|
||||
f"[{style}]● {status}[/{style}]",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No vector databases found. Create one with 'hanzo vector create'[/dim]")
|
||||
if not dbs:
|
||||
console.print("[dim]No vector databases found. Create one with 'hanzo vector create'[/dim]")
|
||||
|
||||
|
||||
@vector_group.command(name="describe")
|
||||
@click.argument("name")
|
||||
def vector_describe(name: str):
|
||||
"""Show vector database details."""
|
||||
resp = _request("get", f"/v1/databases/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
status = data.get("status", "unknown")
|
||||
style = "green" if status == "running" else "yellow"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Name:[/cyan] {name}\n"
|
||||
f"[cyan]Status:[/cyan] [green]● Running[/green]\n"
|
||||
f"[cyan]Collections:[/cyan] 3\n"
|
||||
f"[cyan]Total vectors:[/cyan] 1,000,000\n"
|
||||
f"[cyan]Storage:[/cyan] 2.5 GB\n"
|
||||
f"[cyan]Default metric:[/cyan] cosine\n"
|
||||
f"[cyan]Endpoint:[/cyan] https://vector.hanzo.ai/{name}",
|
||||
f"[cyan]Name:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Status:[/cyan] [{style}]● {status}[/{style}]\n"
|
||||
f"[cyan]Collections:[/cyan] {data.get('collection_count', 0)}\n"
|
||||
f"[cyan]Total vectors:[/cyan] {data.get('vector_count', 0):,}\n"
|
||||
f"[cyan]Storage:[/cyan] {data.get('storage', '0 B')}\n"
|
||||
f"[cyan]Default metric:[/cyan] {data.get('default_metric', 'cosine')}\n"
|
||||
f"[cyan]Region:[/cyan] {data.get('region', '-')}\n"
|
||||
f"[cyan]Endpoint:[/cyan] {data.get('endpoint', f'{VECTOR_URL}/{name}')}",
|
||||
title="Vector Database Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -100,6 +142,9 @@ def vector_delete(name: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete vector database '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/databases/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Vector database '{name}' deleted")
|
||||
|
||||
|
||||
@@ -128,7 +173,15 @@ def collections_create(name: str, db: str, dimension: int, metric: str, index_ty
|
||||
hanzo vector collections create embeddings --dim 1536
|
||||
hanzo vector collections create images --dim 512 --metric euclidean
|
||||
"""
|
||||
payload = {"name": name, "dimension": dimension, "index_type": index_type}
|
||||
if metric:
|
||||
payload["metric"] = metric
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Collection '{name}' created")
|
||||
console.print(f" ID: {data.get('id', '-')}")
|
||||
console.print(f" Dimension: {dimension}")
|
||||
console.print(f" Index type: {index_type}")
|
||||
if metric:
|
||||
@@ -139,6 +192,10 @@ def collections_create(name: str, db: str, dimension: int, metric: str, index_ty
|
||||
@click.option("--db", "-d", default="default")
|
||||
def collections_list(db: str):
|
||||
"""List collections."""
|
||||
resp = _request("get", f"/v1/databases/{db}/collections")
|
||||
data = check_response(resp)
|
||||
items = data.get("collections", data.get("items", []))
|
||||
|
||||
table = Table(title=f"Collections in '{db}'", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Dimension", style="white")
|
||||
@@ -146,8 +203,18 @@ def collections_list(db: str):
|
||||
table.add_column("Metric", style="yellow")
|
||||
table.add_column("Index", style="dim")
|
||||
|
||||
for c in items:
|
||||
table.add_row(
|
||||
c.get("name", ""),
|
||||
str(c.get("dimension", "-")),
|
||||
str(c.get("vector_count", 0)),
|
||||
c.get("metric", "cosine"),
|
||||
c.get("index_type", "hnsw"),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No collections found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No collections found[/dim]")
|
||||
|
||||
|
||||
@collections.command(name="describe")
|
||||
@@ -155,14 +222,17 @@ def collections_list(db: str):
|
||||
@click.option("--db", "-d", default="default")
|
||||
def collections_describe(name: str, db: str):
|
||||
"""Show collection details."""
|
||||
resp = _request("get", f"/v1/databases/{db}/collections/{name}")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Collection:[/cyan] {name}\n"
|
||||
f"[cyan]Dimension:[/cyan] 1536\n"
|
||||
f"[cyan]Vectors:[/cyan] 100,000\n"
|
||||
f"[cyan]Metric:[/cyan] cosine\n"
|
||||
f"[cyan]Index:[/cyan] HNSW (M=16, efConstruction=200)\n"
|
||||
f"[cyan]Storage:[/cyan] 580 MB",
|
||||
f"[cyan]Collection:[/cyan] {data.get('name', name)}\n"
|
||||
f"[cyan]Dimension:[/cyan] {data.get('dimension', '-')}\n"
|
||||
f"[cyan]Vectors:[/cyan] {data.get('vector_count', 0):,}\n"
|
||||
f"[cyan]Metric:[/cyan] {data.get('metric', 'cosine')}\n"
|
||||
f"[cyan]Index:[/cyan] {data.get('index_type', 'hnsw')}\n"
|
||||
f"[cyan]Storage:[/cyan] {data.get('storage', '0 B')}",
|
||||
title="Collection Details",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -180,6 +250,9 @@ def collections_delete(name: str, db: str, force: bool):
|
||||
|
||||
if not Confirm.ask(f"[red]Delete collection '{name}'?[/red]"):
|
||||
return
|
||||
|
||||
resp = _request("delete", f"/v1/databases/{db}/collections/{name}")
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Collection '{name}' deleted")
|
||||
|
||||
|
||||
@@ -204,10 +277,43 @@ def vector_upsert(collection: str, db: str, source: str, id_field: str, vector_f
|
||||
hanzo vector upsert -c docs --from embeddings.jsonl
|
||||
hanzo vector upsert -c products --from data.jsonl --embed openai:text-embedding-3-small
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
source_path = Path(source)
|
||||
if not source_path.exists():
|
||||
raise click.ClickException(f"Source file not found: {source}")
|
||||
|
||||
console.print(f"[cyan]Upserting into '{collection}' from '{source}'...[/cyan]")
|
||||
console.print("[green]✓[/green] Upsert complete")
|
||||
console.print(" Vectors: 0")
|
||||
console.print(" Errors: 0")
|
||||
|
||||
vectors = []
|
||||
with open(source_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
vectors.append(json.loads(line))
|
||||
|
||||
total = 0
|
||||
errors = 0
|
||||
for i in range(0, len(vectors), batch_size):
|
||||
batch = vectors[i : i + batch_size]
|
||||
payload = {
|
||||
"vectors": batch,
|
||||
"vector_field": vector_field,
|
||||
}
|
||||
if id_field:
|
||||
payload["id_field"] = id_field
|
||||
if embed:
|
||||
payload["embed_model"] = embed
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/upsert", json=payload)
|
||||
result = check_response(resp)
|
||||
total += result.get("upserted", len(batch))
|
||||
errors += result.get("errors", 0)
|
||||
console.print(f" Batch {i // batch_size + 1}: {result.get('upserted', len(batch))} upserted")
|
||||
|
||||
console.print(f"[green]✓[/green] Upsert complete")
|
||||
console.print(f" Vectors: {total}")
|
||||
console.print(f" Errors: {errors}")
|
||||
|
||||
|
||||
@vector_group.command(name="query")
|
||||
@@ -238,8 +344,46 @@ def vector_query(
|
||||
hanzo vector query -c docs -t "machine learning" -k 20
|
||||
hanzo vector query -c images -v "[0.1, 0.2, ...]" --filter "category=animals"
|
||||
"""
|
||||
console.print(f"[cyan]Searching '{collection}'...[/cyan]")
|
||||
console.print("[dim]No results found[/dim]")
|
||||
if not text and not vector:
|
||||
raise click.ClickException("Provide --text or --vector for query")
|
||||
|
||||
payload = {
|
||||
"top_k": topk,
|
||||
"include_vectors": include_vectors,
|
||||
"include_metadata": include_metadata,
|
||||
}
|
||||
if text:
|
||||
payload["text"] = text
|
||||
if vector:
|
||||
payload["vector"] = json.loads(vector)
|
||||
if filter:
|
||||
payload["filter"] = filter
|
||||
if embed:
|
||||
payload["embed_model"] = embed
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/query", json=payload)
|
||||
data = check_response(resp)
|
||||
matches = data.get("matches", data.get("results", []))
|
||||
|
||||
table = Table(title=f"Results from '{collection}'", box=box.ROUNDED)
|
||||
table.add_column("#", style="dim")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Score", style="green")
|
||||
table.add_column("Metadata", style="white")
|
||||
|
||||
for i, m in enumerate(matches, 1):
|
||||
metadata = m.get("metadata", {})
|
||||
meta_str = json.dumps(metadata, default=str)[:80] if metadata else "-"
|
||||
table.add_row(
|
||||
str(i),
|
||||
str(m.get("id", ""))[:24],
|
||||
f"{m.get('score', 0):.4f}",
|
||||
meta_str,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
if not matches:
|
||||
console.print("[dim]No results found[/dim]")
|
||||
|
||||
|
||||
@vector_group.command(name="fetch")
|
||||
@@ -249,8 +393,26 @@ def vector_query(
|
||||
@click.option("--include-vectors", is_flag=True)
|
||||
def vector_fetch(ids: tuple, collection: str, db: str, include_vectors: bool):
|
||||
"""Fetch vectors by ID."""
|
||||
console.print(f"[cyan]Fetching {len(ids)} vector(s) from '{collection}'...[/cyan]")
|
||||
console.print("[dim]No vectors found[/dim]")
|
||||
payload = {"ids": list(ids), "include_vectors": include_vectors}
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/fetch", json=payload)
|
||||
data = check_response(resp)
|
||||
vectors = data.get("vectors", [])
|
||||
|
||||
for v in vectors:
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]ID:[/cyan] {v.get('id', '-')}\n"
|
||||
f"[cyan]Metadata:[/cyan] {json.dumps(v.get('metadata', {}), indent=2, default=str)}",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
if include_vectors and v.get("values"):
|
||||
dims = v["values"][:5]
|
||||
console.print(f" Vector: [{', '.join(f'{d:.4f}' for d in dims)}, ...] ({len(v['values'])} dims)")
|
||||
|
||||
if not vectors:
|
||||
console.print("[dim]No vectors found[/dim]")
|
||||
|
||||
|
||||
@vector_group.command(name="delete-vectors")
|
||||
@@ -267,7 +429,18 @@ def vector_delete_vectors(collection: str, db: str, ids: str, filter: str, delet
|
||||
|
||||
if not Confirm.ask(f"[red]Delete ALL vectors from '{collection}'?[/red]"):
|
||||
return
|
||||
console.print(f"[green]✓[/green] Vectors deleted from '{collection}'")
|
||||
|
||||
payload = {}
|
||||
if ids:
|
||||
payload["ids"] = [i.strip() for i in ids.split(",")]
|
||||
if filter:
|
||||
payload["filter"] = filter
|
||||
if delete_all:
|
||||
payload["delete_all"] = True
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/delete", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Deleted {data.get('deleted', 0)} vector(s) from '{collection}'")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -286,14 +459,29 @@ def indexes():
|
||||
@click.option("--db", "-d", default="default")
|
||||
def indexes_list(collection: str, db: str):
|
||||
"""List indexes on a collection."""
|
||||
resp = _request("get", f"/v1/databases/{db}/collections/{collection}/indexes")
|
||||
data = check_response(resp)
|
||||
items = data.get("indexes", [])
|
||||
|
||||
table = Table(title=f"Indexes on '{collection}'", box=box.ROUNDED)
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Type", style="white")
|
||||
table.add_column("Metric", style="yellow")
|
||||
table.add_column("Parameters", style="dim")
|
||||
|
||||
for idx in items:
|
||||
params = idx.get("parameters", {})
|
||||
param_str = ", ".join(f"{k}={v}" for k, v in params.items()) if params else "-"
|
||||
table.add_row(
|
||||
idx.get("name", "-"),
|
||||
idx.get("type", "-"),
|
||||
idx.get("metric", "-"),
|
||||
param_str,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print("[dim]No indexes found[/dim]")
|
||||
if not items:
|
||||
console.print("[dim]No indexes found[/dim]")
|
||||
|
||||
|
||||
@indexes.command(name="create")
|
||||
@@ -315,10 +503,27 @@ def indexes_create(
|
||||
hanzo vector indexes create embeddings --type hnsw --hnsw-m 32
|
||||
hanzo vector indexes create images --type ivf --ivf-nlist 256
|
||||
"""
|
||||
payload = {"type": idx_type}
|
||||
if name:
|
||||
payload["name"] = name
|
||||
if metric:
|
||||
payload["metric"] = metric
|
||||
|
||||
if idx_type == "hnsw":
|
||||
payload["parameters"] = {"m": hnsw_m, "ef_construction": hnsw_ef}
|
||||
elif idx_type == "ivf":
|
||||
payload["parameters"] = {"nlist": ivf_nlist}
|
||||
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/indexes", json=payload)
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(f"[green]✓[/green] Index created on '{collection}'")
|
||||
console.print(f" Name: {data.get('name', name or 'default')}")
|
||||
console.print(f" Type: {idx_type}")
|
||||
if idx_type == "hnsw":
|
||||
console.print(f" M: {hnsw_m}, efConstruction: {hnsw_ef}")
|
||||
elif idx_type == "ivf":
|
||||
console.print(f" nlist: {ivf_nlist}")
|
||||
|
||||
|
||||
@indexes.command(name="rebuild")
|
||||
@@ -327,8 +532,14 @@ def indexes_create(
|
||||
@click.option("--name", "-n", help="Specific index name")
|
||||
def indexes_rebuild(collection: str, db: str, name: str):
|
||||
"""Rebuild vector index."""
|
||||
payload = {}
|
||||
if name:
|
||||
payload["index_name"] = name
|
||||
|
||||
console.print(f"[cyan]Rebuilding index for '{collection}'...[/cyan]")
|
||||
console.print("[green]✓[/green] Index rebuilt")
|
||||
resp = _request("post", f"/v1/databases/{db}/collections/{collection}/indexes/rebuild", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Index rebuilt ({data.get('duration', '-')})")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -340,14 +551,17 @@ def indexes_rebuild(collection: str, db: str, name: str):
|
||||
@click.option("--db", "-d", default="default")
|
||||
def vector_stats(db: str):
|
||||
"""Show database statistics."""
|
||||
resp = _request("get", f"/v1/databases/{db}/stats")
|
||||
data = check_response(resp)
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
f"[cyan]Database:[/cyan] {db}\n"
|
||||
f"[cyan]Collections:[/cyan] 0\n"
|
||||
f"[cyan]Total vectors:[/cyan] 0\n"
|
||||
f"[cyan]Storage:[/cyan] 0 B\n"
|
||||
f"[cyan]Queries/day:[/cyan] 0\n"
|
||||
f"[cyan]Avg latency:[/cyan] 0ms",
|
||||
f"[cyan]Database:[/cyan] {data.get('name', db)}\n"
|
||||
f"[cyan]Collections:[/cyan] {data.get('collection_count', 0)}\n"
|
||||
f"[cyan]Total vectors:[/cyan] {data.get('vector_count', 0):,}\n"
|
||||
f"[cyan]Storage:[/cyan] {data.get('storage', '0 B')}\n"
|
||||
f"[cyan]Queries/day:[/cyan] {data.get('queries_per_day', 0):,}\n"
|
||||
f"[cyan]Avg latency:[/cyan] {data.get('avg_latency_ms', 0)}ms",
|
||||
title="Vector Statistics",
|
||||
border_style="cyan",
|
||||
)
|
||||
@@ -360,6 +574,12 @@ def vector_stats(db: str):
|
||||
@click.option("--env", "-e", help="Environment")
|
||||
def vector_bind(service: str, db: str, env: str):
|
||||
"""Bind database to a service."""
|
||||
payload = {"service": service, "database": db}
|
||||
if env:
|
||||
payload["environment"] = env
|
||||
|
||||
resp = _request("post", "/v1/bindings", json=payload)
|
||||
check_response(resp)
|
||||
console.print(f"[green]✓[/green] Bound '{db}' to service '{service}'")
|
||||
if env:
|
||||
console.print(f" Environment: {env}")
|
||||
@@ -371,5 +591,14 @@ def vector_bind(service: str, db: str, env: str):
|
||||
@click.option("--output", "-o", help="Output location")
|
||||
def vector_backup(db: str, collection: str, output: str):
|
||||
"""Backup vector data."""
|
||||
console.print("[green]✓[/green] Backup created")
|
||||
console.print(" Backup ID: bak_abc123")
|
||||
payload = {"database": db}
|
||||
if collection:
|
||||
payload["collection"] = collection
|
||||
if output:
|
||||
payload["output"] = output
|
||||
|
||||
resp = _request("post", "/v1/backups", json=payload)
|
||||
data = check_response(resp)
|
||||
console.print(f"[green]✓[/green] Backup created")
|
||||
console.print(f" Backup ID: {data.get('id', '-')}")
|
||||
console.print(f" Size: {data.get('size', '-')}")
|
||||
|
||||
Reference in New Issue
Block a user