runspec-grafana-core 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runspec_grafana_core-0.1.0/.gitignore +61 -0
- runspec_grafana_core-0.1.0/CHANGELOG.md +43 -0
- runspec_grafana_core-0.1.0/PKG-INFO +10 -0
- runspec_grafana_core-0.1.0/pyproject.toml +32 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/__init__.py +113 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/alerting.py +192 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/annotations.py +131 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/api.py +37 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/client.py +346 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/dashboards.py +278 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/datasources.py +105 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/loki.py +165 -0
- runspec_grafana_core-0.1.0/runspec_grafana_core/prometheus.py +252 -0
- runspec_grafana_core-0.1.0/tests/__init__.py +0 -0
- runspec_grafana_core-0.1.0/tests/conftest.py +59 -0
- runspec_grafana_core-0.1.0/tests/test_api.py +374 -0
- runspec_grafana_core-0.1.0/tests/test_client.py +128 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg
|
|
8
|
+
*.egg-info/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
.eggs/
|
|
12
|
+
.venv/
|
|
13
|
+
venv/
|
|
14
|
+
env/
|
|
15
|
+
.env
|
|
16
|
+
pip-wheel-metadata/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.mypy_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
htmlcov/
|
|
21
|
+
.coverage
|
|
22
|
+
coverage.xml
|
|
23
|
+
*.cover
|
|
24
|
+
|
|
25
|
+
# Node
|
|
26
|
+
node_modules/
|
|
27
|
+
dist/
|
|
28
|
+
*.js.map
|
|
29
|
+
.npm
|
|
30
|
+
|
|
31
|
+
# Go
|
|
32
|
+
*.exe
|
|
33
|
+
*.test
|
|
34
|
+
*.out
|
|
35
|
+
vendor/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.iml
|
|
41
|
+
*.iws
|
|
42
|
+
*.ipr
|
|
43
|
+
.DS_Store
|
|
44
|
+
Thumbs.db
|
|
45
|
+
|
|
46
|
+
# Docs
|
|
47
|
+
site/
|
|
48
|
+
|
|
49
|
+
# Misc
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
52
|
+
|
|
53
|
+
# External reference repos (cloned locally, not committed)
|
|
54
|
+
chainlit-docs/
|
|
55
|
+
.chainlit/
|
|
56
|
+
|
|
57
|
+
# Claude Code local config (machine-specific)
|
|
58
|
+
.claude/launch.json
|
|
59
|
+
|
|
60
|
+
# Stray committed test venv (removed from tracking)
|
|
61
|
+
.venv-test/
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog — runspec-grafana-core
|
|
2
|
+
|
|
3
|
+
## [0.1.0]
|
|
4
|
+
|
|
5
|
+
First release. Pure-Python helpers over the Grafana HTTP API, ported from the
|
|
6
|
+
read tools of the [Grafana MCP server](https://github.com/grafana/mcp-grafana)
|
|
7
|
+
(no runspec dependency, no runnables). Results are compact by default:
|
|
8
|
+
|
|
9
|
+
- **Identity and search:** `whoami`, `health`, `search_dashboards`,
|
|
10
|
+
`search_folders`.
|
|
11
|
+
- **Dashboards:** `get_dashboard` returns a summary (folder, tags, variables,
|
|
12
|
+
one row per panel, rows unpacked); `include=["queries"]` adds each panel's
|
|
13
|
+
query text and datasource, `panel_id` narrows to one panel, `version` loads a
|
|
14
|
+
saved version, `raw=True` returns the API response. `list_dashboard_versions`
|
|
15
|
+
handles both the list and the Grafana 12 `{"versions": [...]}` shapes.
|
|
16
|
+
- **Datasources:** `list_datasources`, `get_datasource` (secrets named, never
|
|
17
|
+
shown), `check_datasources` (health checks, failures reported not raised),
|
|
18
|
+
and `resolve_datasource`, which takes a UID or a name, or picks the default
|
|
19
|
+
(or only) datasource of a type when none is given.
|
|
20
|
+
- **Prometheus through Grafana:** `query_prometheus` (instant or range; range
|
|
21
|
+
results summarised per series unless `include_values`),
|
|
22
|
+
`query_prometheus_histogram` (builds the `histogram_quantile` query),
|
|
23
|
+
`list_prometheus_metric_names` (with a regex filter),
|
|
24
|
+
`list_prometheus_label_names`, `list_prometheus_label_values`,
|
|
25
|
+
`list_prometheus_metric_metadata`.
|
|
26
|
+
- **Loki through Grafana:** `query_loki_logs` (lines newest first, stream
|
|
27
|
+
labels listed once, each line cut to `max_line_chars`; metric queries
|
|
28
|
+
summarised), `list_loki_label_names`, `list_loki_label_values`,
|
|
29
|
+
`query_loki_stats`, `query_loki_patterns`.
|
|
30
|
+
- **Alerting:** `list_alert_rules` (firing first, counts by state, Grafana-managed
|
|
31
|
+
or a datasource's own rules, only active alert instances counted),
|
|
32
|
+
`get_alert_rule`, `list_contact_points`, `list_silences`.
|
|
33
|
+
- **Annotations:** `get_annotations`, `get_annotation_tags`, `create_annotation`.
|
|
34
|
+
- `api_get`: GET any `/api/` path for what the helpers don't cover.
|
|
35
|
+
- Queries go through the datasource proxy, retried through the plugin's
|
|
36
|
+
`/resources` route on 401/403/404/500/502, so only the Grafana token is needed.
|
|
37
|
+
- `GrafanaConfig` / `config_from_env`: `GRAFANA_URL` (default
|
|
38
|
+
`http://127.0.0.1:3000`), `GRAFANA_SERVICE_ACCOUNT_TOKEN` (or `TOKEN`, `PAT`,
|
|
39
|
+
`API_KEY`) or basic `GRAFANA_USERNAME`/`GRAFANA_PASSWORD`, `GRAFANA_ORG_ID`,
|
|
40
|
+
`GRAFANA_CA_BUNDLE`, `GRAFANA_TLS_VERIFY`, `GRAFANA_PROXY`, with free-form
|
|
41
|
+
`GRAFANA_<ENV>_*` prefixes.
|
|
42
|
+
- Times accept Unix seconds or milliseconds, RFC 3339, `now-1h`, or a duration
|
|
43
|
+
back from now.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: runspec-grafana-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure-Python Grafana HTTP API helpers — the importable core behind runspec-grafana (no runspec dependency, no runnables)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runspec-grafana-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
description = "Pure-Python Grafana HTTP API helpers — the importable core behind runspec-grafana (no runspec dependency, no runnables)"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"httpx",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = [
|
|
16
|
+
"ruff==0.15.20",
|
|
17
|
+
"mypy",
|
|
18
|
+
"pytest>=8.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
|
|
24
|
+
[tool.mypy]
|
|
25
|
+
python_version = "3.10"
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 200
|
|
29
|
+
target-version = "py310"
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""runspec-grafana-core — pure-Python Grafana HTTP API helpers.
|
|
2
|
+
|
|
3
|
+
This package depends only on ``httpx`` — it has **no dependency on runspec** and
|
|
4
|
+
ships **no runspec.toml and no entry points**, so installing it exposes the
|
|
5
|
+
helpers for import without surfacing any runnables (it is invisible to
|
|
6
|
+
``runspec local`` / ``runspec serve`` discovery). ``runspec-grafana`` depends on
|
|
7
|
+
it and wraps each helper as a verb of the ``grafana`` runnable.
|
|
8
|
+
|
|
9
|
+
The helpers port the read tools of the Grafana MCP server
|
|
10
|
+
(https://github.com/grafana/mcp-grafana) onto the Grafana HTTP API: dashboards,
|
|
11
|
+
datasources, PromQL and LogQL through the datasource proxy, alert rules,
|
|
12
|
+
silences, contact points, and annotations. Every operation takes an explicit
|
|
13
|
+
:class:`GrafanaConfig` first argument, so a private wrapper package can inject
|
|
14
|
+
credentials, bake in an instance or org, and build narrower runnables on top.
|
|
15
|
+
Each function returns plain, compact data; failures raise :class:`GrafanaError`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from runspec_grafana_core.alerting import RULE_INCLUDE_OPTIONS, RULE_STATES, SILENCE_STATES, get_alert_rule, list_alert_rules, list_contact_points, list_silences
|
|
19
|
+
from runspec_grafana_core.annotations import ANNOTATION_TYPES, create_annotation, get_annotation_tags, get_annotations
|
|
20
|
+
from runspec_grafana_core.api import api_get, parse_params
|
|
21
|
+
from runspec_grafana_core.client import (
|
|
22
|
+
DEFAULT_URL,
|
|
23
|
+
GrafanaConfig,
|
|
24
|
+
GrafanaError,
|
|
25
|
+
config_from_env,
|
|
26
|
+
datasource_request,
|
|
27
|
+
grafana_request,
|
|
28
|
+
parse_duration,
|
|
29
|
+
parse_time,
|
|
30
|
+
)
|
|
31
|
+
from runspec_grafana_core.dashboards import (
|
|
32
|
+
DASHBOARD_INCLUDE_OPTIONS,
|
|
33
|
+
dashboard_summary,
|
|
34
|
+
get_dashboard,
|
|
35
|
+
health,
|
|
36
|
+
list_dashboard_versions,
|
|
37
|
+
panel_queries,
|
|
38
|
+
search_dashboards,
|
|
39
|
+
search_folders,
|
|
40
|
+
whoami,
|
|
41
|
+
)
|
|
42
|
+
from runspec_grafana_core.datasources import check_datasources, fetch_datasource, get_datasource, list_datasources, resolve_datasource
|
|
43
|
+
from runspec_grafana_core.loki import compact_streams, list_loki_label_names, list_loki_label_values, query_loki_logs, query_loki_patterns, query_loki_stats
|
|
44
|
+
from runspec_grafana_core.prometheus import (
|
|
45
|
+
compact_series,
|
|
46
|
+
histogram_query,
|
|
47
|
+
list_prometheus_label_names,
|
|
48
|
+
list_prometheus_label_values,
|
|
49
|
+
list_prometheus_metric_metadata,
|
|
50
|
+
list_prometheus_metric_names,
|
|
51
|
+
query_prometheus,
|
|
52
|
+
query_prometheus_histogram,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
# client / config
|
|
57
|
+
"GrafanaConfig",
|
|
58
|
+
"GrafanaError",
|
|
59
|
+
"config_from_env",
|
|
60
|
+
"grafana_request",
|
|
61
|
+
"datasource_request",
|
|
62
|
+
"parse_time",
|
|
63
|
+
"parse_duration",
|
|
64
|
+
"DEFAULT_URL",
|
|
65
|
+
# identity / search / dashboards
|
|
66
|
+
"whoami",
|
|
67
|
+
"health",
|
|
68
|
+
"search_dashboards",
|
|
69
|
+
"search_folders",
|
|
70
|
+
"get_dashboard",
|
|
71
|
+
"dashboard_summary",
|
|
72
|
+
"panel_queries",
|
|
73
|
+
"list_dashboard_versions",
|
|
74
|
+
"DASHBOARD_INCLUDE_OPTIONS",
|
|
75
|
+
# datasources
|
|
76
|
+
"list_datasources",
|
|
77
|
+
"get_datasource",
|
|
78
|
+
"fetch_datasource",
|
|
79
|
+
"resolve_datasource",
|
|
80
|
+
"check_datasources",
|
|
81
|
+
# prometheus via grafana
|
|
82
|
+
"query_prometheus",
|
|
83
|
+
"query_prometheus_histogram",
|
|
84
|
+
"histogram_query",
|
|
85
|
+
"compact_series",
|
|
86
|
+
"list_prometheus_metric_names",
|
|
87
|
+
"list_prometheus_label_names",
|
|
88
|
+
"list_prometheus_label_values",
|
|
89
|
+
"list_prometheus_metric_metadata",
|
|
90
|
+
# loki via grafana
|
|
91
|
+
"query_loki_logs",
|
|
92
|
+
"compact_streams",
|
|
93
|
+
"list_loki_label_names",
|
|
94
|
+
"list_loki_label_values",
|
|
95
|
+
"query_loki_stats",
|
|
96
|
+
"query_loki_patterns",
|
|
97
|
+
# alerting
|
|
98
|
+
"list_alert_rules",
|
|
99
|
+
"get_alert_rule",
|
|
100
|
+
"list_contact_points",
|
|
101
|
+
"list_silences",
|
|
102
|
+
"RULE_STATES",
|
|
103
|
+
"RULE_INCLUDE_OPTIONS",
|
|
104
|
+
"SILENCE_STATES",
|
|
105
|
+
# annotations
|
|
106
|
+
"get_annotations",
|
|
107
|
+
"get_annotation_tags",
|
|
108
|
+
"create_annotation",
|
|
109
|
+
"ANNOTATION_TYPES",
|
|
110
|
+
# escape hatch
|
|
111
|
+
"api_get",
|
|
112
|
+
"parse_params",
|
|
113
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""alerting.py — alert rules with their live state, contact points, and silences.
|
|
2
|
+
|
|
3
|
+
Rule state comes from Grafana's Prometheus-compatible rules endpoint
|
|
4
|
+
(``/api/prometheus/grafana/api/v1/rules``, or a datasource's own rules with
|
|
5
|
+
``datasource``); a rule's full definition from the provisioning API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
from runspec_grafana_core.client import GrafanaConfig, GrafanaError, drop_empty, grafana_request, preview, truncate
|
|
14
|
+
from runspec_grafana_core.datasources import fetch_datasource
|
|
15
|
+
|
|
16
|
+
RULE_STATES = ("firing", "pending", "inactive", "error", "nodata")
|
|
17
|
+
RULE_INCLUDE_OPTIONS = ("alerts", "query", "annotations")
|
|
18
|
+
SILENCE_STATES = ("active", "pending", "expired", "any")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_active(alert: dict[str, Any]) -> bool:
|
|
22
|
+
# Grafana lists every instance, Normal ones included ("Normal",
|
|
23
|
+
# "Normal (MissingSeries)"); Prometheus rulers list only pending/firing.
|
|
24
|
+
return not str(alert.get("state") or "").lower().startswith(("normal", "inactive"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rule_row(rule: dict[str, Any], group: dict[str, Any], include: list[str]) -> dict[str, Any]:
|
|
28
|
+
alerts = [a for a in rule.get("alerts") or [] if isinstance(a, dict) and _is_active(a)]
|
|
29
|
+
error, _ = preview(rule.get("lastError"), 300)
|
|
30
|
+
row: dict[str, Any] = drop_empty(
|
|
31
|
+
{
|
|
32
|
+
"uid": rule.get("uid"),
|
|
33
|
+
"name": rule.get("name"),
|
|
34
|
+
"state": rule.get("state"),
|
|
35
|
+
"health": rule.get("health"),
|
|
36
|
+
"folder": group.get("file"),
|
|
37
|
+
"group": group.get("name"),
|
|
38
|
+
"labels": rule.get("labels"),
|
|
39
|
+
"last_error": error,
|
|
40
|
+
"last_evaluation": rule.get("lastEvaluation"),
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
row["active_alerts"] = len(alerts)
|
|
44
|
+
if "query" in include and rule.get("query"):
|
|
45
|
+
row["query"] = rule["query"]
|
|
46
|
+
if "annotations" in include and rule.get("annotations"):
|
|
47
|
+
row["annotations"] = rule["annotations"]
|
|
48
|
+
if "alerts" in include and alerts:
|
|
49
|
+
row["alerts"] = [drop_empty({"labels": a.get("labels"), "state": a.get("state"), "active_at": a.get("activeAt"), "value": a.get("value")}) for a in alerts[:10]]
|
|
50
|
+
return row
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def list_alert_rules(
|
|
54
|
+
config: GrafanaConfig,
|
|
55
|
+
state: str | None = None,
|
|
56
|
+
search: str | None = None,
|
|
57
|
+
folder: str | None = None,
|
|
58
|
+
datasource: str | None = None,
|
|
59
|
+
include: list[str] | None = None,
|
|
60
|
+
limit: int | None = 100,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Alert rules and their current state, firing first.
|
|
63
|
+
|
|
64
|
+
``state`` keeps one of firing/pending/inactive/error/nodata (matched
|
|
65
|
+
against state or health); ``search`` is a case-insensitive substring of
|
|
66
|
+
the rule name; ``folder`` matches the folder title. ``datasource`` reads a
|
|
67
|
+
Prometheus/Loki datasource's own (ruler) rules instead of Grafana-managed
|
|
68
|
+
ones. ``include`` adds ``alerts`` (up to 10 active ones per rule), ``query``,
|
|
69
|
+
``annotations``.
|
|
70
|
+
"""
|
|
71
|
+
include = list(include or [])
|
|
72
|
+
unknown = sorted(set(include) - set(RULE_INCLUDE_OPTIONS))
|
|
73
|
+
if unknown:
|
|
74
|
+
raise GrafanaError(f"Unknown include part(s): {', '.join(unknown)} — choose from {', '.join(RULE_INCLUDE_OPTIONS)}")
|
|
75
|
+
if state and state not in RULE_STATES:
|
|
76
|
+
raise GrafanaError(f"state must be one of {', '.join(RULE_STATES)}")
|
|
77
|
+
source = "grafana"
|
|
78
|
+
if datasource:
|
|
79
|
+
source = str(fetch_datasource(config, datasource).get("uid"))
|
|
80
|
+
body = grafana_request("GET", f"/api/prometheus/{quote(source, safe='')}/api/v1/rules", config=config) or {}
|
|
81
|
+
if isinstance(body, dict) and body.get("status") == "error":
|
|
82
|
+
raise GrafanaError(f"Grafana rules: {body.get('error', 'unknown error')}")
|
|
83
|
+
groups = ((body.get("data") or {}) if isinstance(body, dict) else {}).get("groups") or []
|
|
84
|
+
rows = []
|
|
85
|
+
for group in groups:
|
|
86
|
+
if folder and (group.get("file") or "").lower() != folder.lower():
|
|
87
|
+
continue
|
|
88
|
+
for rule in group.get("rules") or []:
|
|
89
|
+
if not isinstance(rule, dict) or rule.get("type", "alerting") != "alerting":
|
|
90
|
+
continue
|
|
91
|
+
if search and search.lower() not in (rule.get("name") or "").lower():
|
|
92
|
+
continue
|
|
93
|
+
if state and state not in ((rule.get("state") or "").lower(), (rule.get("health") or "").lower()):
|
|
94
|
+
continue
|
|
95
|
+
rows.append(_rule_row(rule, group, include))
|
|
96
|
+
order = {"firing": 0, "pending": 1}
|
|
97
|
+
rows.sort(key=lambda r: (order.get(str(r.get("state")).lower(), 2), r.get("folder") or "", r.get("name") or ""))
|
|
98
|
+
counts: dict[str, int] = {}
|
|
99
|
+
for row in rows:
|
|
100
|
+
counts[str(row.get("state"))] = counts.get(str(row.get("state")), 0) + 1
|
|
101
|
+
kept, cut = truncate(rows, limit)
|
|
102
|
+
return {"count": len(rows), "by_state": counts, "truncated": cut, "rules": kept}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_alert_rule(config: GrafanaConfig, uid: str, raw: bool = False) -> dict[str, Any]:
|
|
106
|
+
"""A Grafana-managed alert rule's definition by UID: condition, queries,
|
|
107
|
+
labels, annotations, pending period, no-data/error handling."""
|
|
108
|
+
rule = grafana_request("GET", f"/api/v1/provisioning/alert-rules/{quote(uid, safe='')}", config=config) or {}
|
|
109
|
+
if raw:
|
|
110
|
+
return rule
|
|
111
|
+
queries = []
|
|
112
|
+
for item in rule.get("data") or []:
|
|
113
|
+
model = item.get("model") or {}
|
|
114
|
+
queries.append(
|
|
115
|
+
drop_empty(
|
|
116
|
+
{
|
|
117
|
+
"ref_id": item.get("refId"),
|
|
118
|
+
"datasource_uid": item.get("datasourceUid"),
|
|
119
|
+
"query": model.get("expr") or model.get("expression") or model.get("query"),
|
|
120
|
+
"type": model.get("type") if item.get("datasourceUid") == "__expr__" else None,
|
|
121
|
+
"range": item.get("relativeTimeRange"),
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return drop_empty(
|
|
126
|
+
{
|
|
127
|
+
"uid": rule.get("uid"),
|
|
128
|
+
"title": rule.get("title"),
|
|
129
|
+
"folder_uid": rule.get("folderUID"),
|
|
130
|
+
"rule_group": rule.get("ruleGroup"),
|
|
131
|
+
"condition": rule.get("condition"),
|
|
132
|
+
"for": rule.get("for"),
|
|
133
|
+
"labels": rule.get("labels"),
|
|
134
|
+
"annotations": rule.get("annotations"),
|
|
135
|
+
"no_data_state": rule.get("noDataState"),
|
|
136
|
+
"exec_err_state": rule.get("execErrState"),
|
|
137
|
+
"is_paused": rule.get("isPaused") or None,
|
|
138
|
+
"updated": rule.get("updated"),
|
|
139
|
+
"queries": queries,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def list_contact_points(config: GrafanaConfig, name: str | None = None, include_settings: bool = False, limit: int | None = 100) -> dict[str, Any]:
|
|
145
|
+
"""Notification contact points: ``[{uid, name, type, settings?}]``. Grafana
|
|
146
|
+
redacts secret settings (webhook URLs, keys) unless the token may read them."""
|
|
147
|
+
items = [c for c in grafana_request("GET", "/api/v1/provisioning/contact-points", config=config, params={"name": name}) or [] if isinstance(c, dict)]
|
|
148
|
+
rows = []
|
|
149
|
+
for c in items:
|
|
150
|
+
row = drop_empty({"uid": c.get("uid"), "name": c.get("name"), "type": c.get("type"), "disable_resolve_message": c.get("disableResolveMessage") or None})
|
|
151
|
+
if include_settings and c.get("settings"):
|
|
152
|
+
row["settings"] = c["settings"]
|
|
153
|
+
rows.append(row)
|
|
154
|
+
kept, cut = truncate(rows, limit)
|
|
155
|
+
return {"count": len(rows), "truncated": cut, "contact_points": kept}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _matchers(matchers: list[dict[str, Any]]) -> str:
|
|
159
|
+
"""Silence matchers as PromQL-style text: ``alertname="X", job=~"api.*"``."""
|
|
160
|
+
ops = {(True, False): "=", (False, False): "!=", (True, True): "=~", (False, True): "!~"}
|
|
161
|
+
out = []
|
|
162
|
+
for m in matchers:
|
|
163
|
+
op = ops[(m.get("isEqual") is not False, bool(m.get("isRegex")))]
|
|
164
|
+
out.append(f'{m.get("name")}{op}"{m.get("value")}"')
|
|
165
|
+
return ", ".join(out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def list_silences(config: GrafanaConfig, state: str = "active", limit: int | None = 100) -> dict[str, Any]:
|
|
169
|
+
"""Alertmanager silences in Grafana's built-in Alertmanager:
|
|
170
|
+
``[{id, state, matchers, starts_at, ends_at, created_by, comment}]``."""
|
|
171
|
+
if state not in SILENCE_STATES:
|
|
172
|
+
raise GrafanaError(f"state must be one of {', '.join(SILENCE_STATES)}")
|
|
173
|
+
items = [s for s in grafana_request("GET", "/api/alertmanager/grafana/api/v2/silences", config=config) or [] if isinstance(s, dict)]
|
|
174
|
+
if state != "any":
|
|
175
|
+
items = [s for s in items if (s.get("status") or {}).get("state") == state]
|
|
176
|
+
items.sort(key=lambda s: s.get("endsAt") or "", reverse=True)
|
|
177
|
+
rows = [
|
|
178
|
+
drop_empty(
|
|
179
|
+
{
|
|
180
|
+
"id": s.get("id"),
|
|
181
|
+
"state": (s.get("status") or {}).get("state"),
|
|
182
|
+
"matchers": _matchers(s.get("matchers") or []),
|
|
183
|
+
"starts_at": s.get("startsAt"),
|
|
184
|
+
"ends_at": s.get("endsAt"),
|
|
185
|
+
"created_by": s.get("createdBy"),
|
|
186
|
+
"comment": preview(s.get("comment"), 300)[0],
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
for s in items
|
|
190
|
+
]
|
|
191
|
+
kept, cut = truncate(rows, limit)
|
|
192
|
+
return {"count": len(rows), "truncated": cut, "silences": kept}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""annotations.py — read and create annotations (``/api/annotations``).
|
|
2
|
+
|
|
3
|
+
Annotations mark events on dashboards: deploys, incidents, alert state changes.
|
|
4
|
+
Grafana stores times as epoch milliseconds; these helpers take the same time
|
|
5
|
+
forms as the query verbs and return RFC 3339.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time as _time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from runspec_grafana_core.client import GrafanaConfig, GrafanaError, drop_empty, grafana_request, iso, parse_time, preview, truncate, window
|
|
14
|
+
|
|
15
|
+
ANNOTATION_TYPES = ("annotation", "alert")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ms(ts: float | None) -> int | None:
|
|
19
|
+
return None if ts is None else int(ts * 1000)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_annotations(
|
|
23
|
+
config: GrafanaConfig,
|
|
24
|
+
start: str | None = None,
|
|
25
|
+
end: str | None = None,
|
|
26
|
+
dashboard_uid: str | None = None,
|
|
27
|
+
panel_id: int | None = None,
|
|
28
|
+
tags: list[str] | None = None,
|
|
29
|
+
match_any: bool = False,
|
|
30
|
+
type: str | None = None,
|
|
31
|
+
limit: int | None = 100,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Annotations in ``start``..``end`` (default the last 24 hours), newest first.
|
|
34
|
+
|
|
35
|
+
``tags`` must all match unless ``match_any``; ``type`` is ``annotation``
|
|
36
|
+
(user-made) or ``alert`` (alert state changes). Text is cut to 300 chars
|
|
37
|
+
(``text_chars`` gives the full length).
|
|
38
|
+
"""
|
|
39
|
+
if type and type not in ANNOTATION_TYPES:
|
|
40
|
+
raise GrafanaError(f"type must be one of {', '.join(ANNOTATION_TYPES)}")
|
|
41
|
+
start_ts, end_ts = window(start, end, default_lookback=86400.0)
|
|
42
|
+
params = {
|
|
43
|
+
"from": _ms(start_ts),
|
|
44
|
+
"to": _ms(end_ts),
|
|
45
|
+
"dashboardUID": dashboard_uid,
|
|
46
|
+
"panelId": panel_id,
|
|
47
|
+
"tags": list(tags or []),
|
|
48
|
+
"matchAny": True if match_any else None,
|
|
49
|
+
"type": type,
|
|
50
|
+
"limit": limit + 1 if limit and limit > 0 else 5000,
|
|
51
|
+
}
|
|
52
|
+
items = [a for a in grafana_request("GET", "/api/annotations", config=config, params=params) or [] if isinstance(a, dict)]
|
|
53
|
+
kept, cut = truncate(items, limit)
|
|
54
|
+
rows = []
|
|
55
|
+
for a in kept:
|
|
56
|
+
text, full = preview(a.get("text"), 300)
|
|
57
|
+
rows.append(
|
|
58
|
+
drop_empty(
|
|
59
|
+
{
|
|
60
|
+
"id": a.get("id"),
|
|
61
|
+
"time": iso(a["time"] / 1000) if a.get("time") else None,
|
|
62
|
+
"time_end": iso(a["timeEnd"] / 1000) if a.get("timeEnd") and a.get("timeEnd") != a.get("time") else None,
|
|
63
|
+
"text": text,
|
|
64
|
+
"text_chars": full,
|
|
65
|
+
"tags": a.get("tags"),
|
|
66
|
+
"dashboard_uid": a.get("dashboardUID"),
|
|
67
|
+
"panel_id": a.get("panelId") or None,
|
|
68
|
+
"alert_name": a.get("alertName"),
|
|
69
|
+
"new_state": a.get("newState"),
|
|
70
|
+
"by": a.get("login") or a.get("email"),
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return {"start": iso(start_ts), "end": iso(end_ts), "count": len(rows), "truncated": cut, "annotations": rows}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_annotation_tags(config: GrafanaConfig, tag: str | None = None, limit: int | None = 100) -> dict[str, Any]:
|
|
78
|
+
"""Annotation tags in use, with how many annotations carry each; ``tag`` filters by prefix."""
|
|
79
|
+
body = grafana_request("GET", "/api/annotations/tags", config=config, params={"tag": tag, "limit": limit if limit and limit > 0 else None}) or {}
|
|
80
|
+
tags = ((body.get("result") or {}) if isinstance(body, dict) else {}).get("tags") or []
|
|
81
|
+
rows = [{"tag": t.get("tag"), "count": t.get("count")} for t in tags if isinstance(t, dict)]
|
|
82
|
+
rows.sort(key=lambda r: r["count"] or 0, reverse=True)
|
|
83
|
+
return {"count": len(rows), "tags": rows}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_annotation(
|
|
87
|
+
config: GrafanaConfig,
|
|
88
|
+
text: str,
|
|
89
|
+
tags: list[str] | None = None,
|
|
90
|
+
dashboard_uid: str | None = None,
|
|
91
|
+
panel_id: int | None = None,
|
|
92
|
+
at: str | None = None,
|
|
93
|
+
end: str | None = None,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""Create an annotation at ``at`` (default now), or a region up to ``end``.
|
|
96
|
+
|
|
97
|
+
Without ``dashboard_uid`` it is an organization-wide annotation, shown on
|
|
98
|
+
any dashboard with a tag-filtered annotation query. Returns ``{id, time, …}``.
|
|
99
|
+
"""
|
|
100
|
+
if not text.strip():
|
|
101
|
+
raise GrafanaError("Annotation text is empty")
|
|
102
|
+
if panel_id is not None and not dashboard_uid:
|
|
103
|
+
raise GrafanaError("panel_id needs dashboard_uid")
|
|
104
|
+
now = _time.time()
|
|
105
|
+
at_ts = parse_time(at, now=now)
|
|
106
|
+
start_ts = at_ts if at_ts is not None else now
|
|
107
|
+
end_ts = parse_time(end, now=now)
|
|
108
|
+
if end_ts is not None and end_ts < start_ts:
|
|
109
|
+
raise GrafanaError(f"end ({iso(end_ts)}) is before the annotation time ({iso(start_ts)})")
|
|
110
|
+
body = drop_empty(
|
|
111
|
+
{
|
|
112
|
+
"text": text,
|
|
113
|
+
"tags": list(tags or []),
|
|
114
|
+
"dashboardUID": dashboard_uid,
|
|
115
|
+
"panelId": panel_id,
|
|
116
|
+
"time": _ms(start_ts),
|
|
117
|
+
"timeEnd": _ms(end_ts),
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
result = grafana_request("POST", "/api/annotations", config=config, json_body=body) or {}
|
|
121
|
+
return drop_empty(
|
|
122
|
+
{
|
|
123
|
+
"id": result.get("id") if isinstance(result, dict) else None,
|
|
124
|
+
"time": iso(start_ts),
|
|
125
|
+
"time_end": iso(end_ts),
|
|
126
|
+
"dashboard_uid": dashboard_uid,
|
|
127
|
+
"panel_id": panel_id,
|
|
128
|
+
"tags": list(tags or []),
|
|
129
|
+
"message": result.get("message") if isinstance(result, dict) else None,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""api.py — a read-only escape hatch: GET any Grafana HTTP API path.
|
|
2
|
+
|
|
3
|
+
For the API surface the dedicated helpers don't cover (org settings, teams,
|
|
4
|
+
library panels, plugin settings …). GET only — anything that changes state
|
|
5
|
+
goes through a helper with its own autonomy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from runspec_grafana_core.client import GrafanaConfig, GrafanaError, grafana_request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_params(pairs: list[str] | None) -> dict[str, Any]:
|
|
16
|
+
"""``["key=value", "tag=a", "tag=b"]`` → ``{"key": "value", "tag": ["a", "b"]}``."""
|
|
17
|
+
out: dict[str, Any] = {}
|
|
18
|
+
for pair in pairs or []:
|
|
19
|
+
key, sep, value = pair.partition("=")
|
|
20
|
+
if not sep or not key.strip():
|
|
21
|
+
raise GrafanaError(f"Query parameter {pair!r} is not key=value")
|
|
22
|
+
key = key.strip()
|
|
23
|
+
if key in out:
|
|
24
|
+
out[key] = [*(out[key] if isinstance(out[key], list) else [out[key]]), value]
|
|
25
|
+
else:
|
|
26
|
+
out[key] = value
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def api_get(config: GrafanaConfig, path: str, params: list[str] | None = None) -> Any:
|
|
31
|
+
"""GET ``path`` (must start with ``/api/``) and return the decoded JSON as-is."""
|
|
32
|
+
path = path.strip()
|
|
33
|
+
if not path.startswith("/api/"):
|
|
34
|
+
raise GrafanaError(f"Path must start with /api/ (got {path!r})")
|
|
35
|
+
if ".." in path.split("?")[0].split("/"):
|
|
36
|
+
raise GrafanaError("Path may not contain '..'")
|
|
37
|
+
return grafana_request("GET", path, config=config, params=parse_params(params))
|