Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/9641.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add CLI commands for prometheus query preset admin CRUD and execution
8 changes: 8 additions & 0 deletions src/ai/backend/client/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ def fair_share() -> None:
"""Fair share scheduler operations (superadmin only)"""


@cli_main.group(
cls=LazyGroup,
import_name="ai.backend.client.cli.prometheus_query_preset:prometheus_query_preset",
)
def prometheus_query_preset() -> None:
"""Prometheus query preset operations."""


@cli_main.group(cls=LazyGroup, import_name="ai.backend.client.cli.resource_usage:resource_usage")
def resource_usage() -> None:
"""Resource usage history operations (superadmin only)"""
Expand Down
1 change: 1 addition & 0 deletions src/ai/backend/client/cli/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def admin() -> None:
keypair,
license, # noqa: A004
manager,
prometheus_query_preset,
resource,
resource_policy,
scaling_group,
Expand Down
157 changes: 157 additions & 0 deletions src/ai/backend/client/cli/admin/prometheus_query_preset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import sys
from typing import Any
from uuid import UUID

import click

from ai.backend.cli.params import JSONParamType
from ai.backend.cli.types import ExitCode
from ai.backend.client.cli.extensions import pass_ctx_obj
from ai.backend.client.cli.pretty import print_done
from ai.backend.client.cli.types import CLIContext
from ai.backend.client.session import Session

from . import admin


@admin.group()
def prometheus_query_preset() -> None:
"""Prometheus query preset administration commands."""


@prometheus_query_preset.command()
@pass_ctx_obj
def list(ctx: CLIContext) -> None:
Comment on lines +22 to +24
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def list(...) shadows the Python built-in list, and this repo appears to enforce built-in name checks (e.g., # noqa: A004 for license). Consider renaming the function (e.g., list_ / list_cmd) while keeping the CLI command name via @prometheus_query_preset.command(name=\"list\").

Suggested change
@prometheus_query_preset.command()
@pass_ctx_obj
def list(ctx: CLIContext) -> None:
@prometheus_query_preset.command(name="list")
@pass_ctx_obj
def list_cmd(ctx: CLIContext) -> None:

Copilot uses AI. Check for mistakes.
"""List all prometheus query presets."""
with Session() as session:
try:
items = session.PrometheusQueryPreset.list_presets()
if not items:
print("No presets found.")
return
for preset in items:
print(f"ID: {preset['id']}")
print(f" Name: {preset['name']}")
print(f" Metric: {preset['metric_name']}")
print(f" Time Window: {preset.get('time_window', '-')}")
print(f" Created: {preset['created_at']}")
print()
except Exception as e:
ctx.output.print_error(e)
sys.exit(ExitCode.FAILURE)


@prometheus_query_preset.command()
@pass_ctx_obj
@click.argument("preset_id", type=str)
def info(ctx: CLIContext, preset_id: str) -> None:
"""Show details of a prometheus query preset."""
with Session() as session:
try:
preset = session.PrometheusQueryPreset.get(UUID(preset_id))
print(f"ID: {preset['id']}")
print(f"Name: {preset['name']}")
print(f"Metric Name: {preset['metric_name']}")
print(f"Query Template: {preset['query_template']}")
print(f"Time Window: {preset.get('time_window', '-')}")
options = preset.get("options", {})
print(f"Filter Labels: {options.get('filter_labels', [])}")
print(f"Group Labels: {options.get('group_labels', [])}")
print(f"Created: {preset['created_at']}")
print(f"Updated: {preset['updated_at']}")
except Exception as e:
ctx.output.print_error(e)
sys.exit(ExitCode.FAILURE)


@prometheus_query_preset.command()
@pass_ctx_obj
@click.option("--name", type=str, required=True, help="Preset name.")
@click.option("--metric-name", type=str, required=True, help="Prometheus metric name.")
@click.option("--query-template", type=str, required=True, help="PromQL template.")
@click.option("--time-window", type=str, default=None, help="Default time window (e.g. 5m).")
@click.option(
"--options",
type=JSONParamType(),
default=None,
help='Preset options JSON (e.g. \'{"filter_labels":["k"],"group_labels":["k"]}\').',
)
def add(
ctx: CLIContext,
name: str,
metric_name: str,
query_template: str,
time_window: str | None,
options: dict[str, Any] | None,
) -> None:
"""Create a new prometheus query preset."""
with Session() as session:
try:
result = session.PrometheusQueryPreset.create(
name,
metric_name,
query_template,
time_window=time_window,
options=options,
)
print(f"Created preset: {result['id']}")
print_done("Done.")
except Exception as e:
ctx.output.print_error(e)
sys.exit(ExitCode.FAILURE)


@prometheus_query_preset.command()
@pass_ctx_obj
@click.argument("preset_id", type=str)
@click.option("--name", type=str, default=None, help="New preset name.")
@click.option("--metric-name", type=str, default=None, help="New Prometheus metric name.")
@click.option("--query-template", type=str, default=None, help="New PromQL template.")
@click.option("--time-window", type=str, default=None, help="New default time window.")
@click.option(
"--options",
type=JSONParamType(),
default=None,
help="New preset options JSON.",
)
Comment on lines +104 to +116
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modify command allows invoking the API with no fields provided (all options default to None), which will send an empty PATCH body and likely fail server-side. Add a client-side validation before calling modify to require at least one of name/metric_name/query_template/time_window/options to be set; otherwise exit with ExitCode.INVALID_ARGUMENT (or raise a Click parameter error).

Copilot uses AI. Check for mistakes.
def modify(
ctx: CLIContext,
preset_id: str,
name: str | None,
metric_name: str | None,
query_template: str | None,
time_window: str | None,
options: dict[str, Any] | None,
) -> None:
"""Modify an existing prometheus query preset."""
with Session() as session:
try:
result = session.PrometheusQueryPreset.modify(
UUID(preset_id),
name=name,
metric_name=metric_name,
query_template=query_template,
time_window=time_window,
options=options,
)
Comment on lines +129 to +136
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modify command allows invoking the API with no fields provided (all options default to None), which will send an empty PATCH body and likely fail server-side. Add a client-side validation before calling modify to require at least one of name/metric_name/query_template/time_window/options to be set; otherwise exit with ExitCode.INVALID_ARGUMENT (or raise a Click parameter error).

Copilot uses AI. Check for mistakes.
print(f"Modified preset: {result['id']}")
print_done("Done.")
except Exception as e:
ctx.output.print_error(e)
sys.exit(ExitCode.FAILURE)


@prometheus_query_preset.command()
@pass_ctx_obj
@click.argument("preset_id", type=str)
@click.confirmation_option(prompt="Are you sure you want to delete this preset?")
def delete(ctx: CLIContext, preset_id: str) -> None:
"""Delete a prometheus query preset."""
with Session() as session:
try:
_result = session.PrometheusQueryPreset.delete(UUID(preset_id))
print(f"Deleted preset: {preset_id}")
print_done("Done.")
except Exception as e:
ctx.output.print_error(e)
sys.exit(ExitCode.FAILURE)
11 changes: 11 additions & 0 deletions src/ai/backend/client/cli/prometheus_query_preset/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Prometheus Query Preset CLI package."""

import click


@click.group()
def prometheus_query_preset() -> None:
"""Prometheus query preset operations."""


from . import commands # noqa
76 changes: 76 additions & 0 deletions src/ai/backend/client/cli/prometheus_query_preset/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Execute command for prometheus query presets."""

from __future__ import annotations

import json
import sys
from uuid import UUID

import click

from ai.backend.cli.types import ExitCode
from ai.backend.client.cli.extensions import pass_ctx_obj
from ai.backend.client.cli.types import CLIContext
from ai.backend.client.session import Session

from . import prometheus_query_preset


@prometheus_query_preset.command()
@pass_ctx_obj
@click.argument("preset_id", type=str)
@click.option("--start", type=str, required=True, help="Start time (ISO8601).")
@click.option("--end", type=str, required=True, help="End time (ISO8601).")
@click.option("--step", type=str, required=True, help="Step duration (e.g. 60s).")
@click.option(
"--label",
"labels",
multiple=True,
type=str,
help="Label filter in key=value format (repeatable).",
)
@click.option(
"--group-labels",
type=str,
default=None,
help="Comma-separated group labels.",
)
@click.option("--window", type=str, default=None, help="Time window override.")
def execute(
ctx: CLIContext,
preset_id: str,
start: str,
end: str,
step: str,
labels: tuple[str, ...],
group_labels: str | None,
window: str | None,
) -> None:
"""Execute a prometheus query preset."""
with Session() as session:
try:
label_entries: list[dict[str, str]] = []
for label in labels:
if "=" not in label:
print(f"Invalid label format: {label} (expected key=value)", file=sys.stderr)
sys.exit(ExitCode.INVALID_ARGUMENT)
key, value = label.split("=", 1)
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This accepts --label values like =foo or bar= and will send empty keys/values to the API. Validate that both key and value are non-empty (and consider stripping whitespace) before appending; if invalid, fail with ExitCode.INVALID_ARGUMENT (or raise click.BadParameter).

Suggested change
key, value = label.split("=", 1)
key, value = label.split("=", 1)
key = key.strip()
value = value.strip()
if not key or not value:
print(
f"Invalid label key or value: {label} (both key and value must be non-empty)",
file=sys.stderr,
)
sys.exit(ExitCode.INVALID_ARGUMENT)

Copilot uses AI. Check for mistakes.
label_entries.append({"key": key, "value": value})

group_labels_list: list[str] | None = None
if group_labels is not None:
group_labels_list = [gl.strip() for gl in group_labels.split(",") if gl.strip()]

response = session.PrometheusQueryPreset.execute(
UUID(preset_id),
start=start,
end=end,
step=step,
labels=label_entries if label_entries else None,
group_labels=group_labels_list,
window=window,
)
print(json.dumps(response.model_dump(mode="json"), indent=2, default=str))
except Exception as e:
ctx.output.print_error(e)
sys.exit(ExitCode.FAILURE)
Loading
Loading