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:
zooqueen
2026-02-21 13:46:55 -08:00
parent 16459919ad
commit 450cbbf689
21 changed files with 4885 additions and 804 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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]"},
+267 -70
View File
@@ -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)
+37
View File
@@ -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
# ============================================================================
+294 -52
View File
@@ -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}'")
+234 -28
View File
@@ -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")
+295 -36
View File
@@ -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)")
+215 -88
View File
@@ -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")
+251 -39
View File
@@ -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]")
+326 -52
View File
@@ -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")
+150 -12
View File
@@ -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', '-'))}")
+116 -10
View File
@@ -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",
)
+470 -46
View File
@@ -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
+356 -55
View File
@@ -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")
+161 -22
View File
@@ -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")
+174 -36
View File
@@ -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")
+244 -34
View File
@@ -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', '-')}")
+132 -11
View File
@@ -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]")
+223 -18
View File
@@ -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}'")
+262 -33
View File
@@ -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', '-')}")