dash-control 0.1.1__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.
@@ -0,0 +1,265 @@
1
+ name: Weekly Release
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "0 9 * * 1" # Every Monday 09:00 UTC
6
+ workflow_dispatch:
7
+ inputs:
8
+ release_note:
9
+ description: "Optional release note (shown in GitHub release body)"
10
+ required: false
11
+ default: ""
12
+
13
+ jobs:
14
+ # ── Gate: tests must pass ────────────────────────────────────────────────
15
+ test:
16
+ name: Test (Python ${{ matrix.python-version }})
17
+ runs-on: ubuntu-latest
18
+ strategy:
19
+ fail-fast: true
20
+ matrix:
21
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+
29
+ - name: Install
30
+ run: pip install -e ".[dev]" pytest pytest-cov
31
+
32
+ - name: Run tests
33
+ run: pytest tests/ -v --cov=dashcontrol --cov-report=xml --cov-report=term-missing
34
+
35
+ - name: Upload coverage
36
+ uses: codecov/codecov-action@v4
37
+ with:
38
+ files: coverage.xml
39
+ fail_ci_if_error: false
40
+
41
+ # ── Generate docs ────────────────────────────────────────────────────────
42
+ docs:
43
+ name: Generate API docs
44
+ runs-on: ubuntu-latest
45
+ needs: test
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+
49
+ - uses: actions/setup-python@v5
50
+ with:
51
+ python-version: "3.11"
52
+
53
+ - name: Install
54
+ run: pip install -e ".[dev]" pdoc
55
+
56
+ - name: Generate docs
57
+ run: |
58
+ pdoc dashcontrol --output-dir docs/api --docformat google
59
+ echo "Docs generated at $(date -u)" > docs/api/.generated
60
+
61
+ - name: Upload docs artifact
62
+ uses: actions/upload-artifact@v4
63
+ with:
64
+ name: api-docs
65
+ path: docs/api/
66
+
67
+ # ── Release: tag, GitHub release, commit docs ────────────────────────────
68
+ release:
69
+ name: Bump version & release
70
+ runs-on: ubuntu-latest
71
+ needs: [test, docs]
72
+ permissions:
73
+ contents: write
74
+ pull-requests: write
75
+ outputs:
76
+ version: ${{ steps.bump.outputs.version }}
77
+ steps:
78
+ - uses: actions/checkout@v4
79
+ with:
80
+ fetch-depth: 0
81
+
82
+ - uses: actions/setup-python@v5
83
+ with:
84
+ python-version: "3.11"
85
+
86
+ - name: Install build tools
87
+ run: pip install hatch pdoc
88
+
89
+ - name: Bump patch version
90
+ id: bump
91
+ run: |
92
+ current=$(hatch version)
93
+ hatch version patch
94
+ new=$(hatch version)
95
+ echo "version=$new" >> $GITHUB_OUTPUT
96
+ echo "prev_version=$current" >> $GITHUB_OUTPUT
97
+ echo "Bumped $current → $new"
98
+
99
+ - name: Regenerate docs into repo
100
+ run: |
101
+ pip install -e ".[dev]"
102
+ pdoc dashcontrol --output-dir docs/api --docformat google
103
+
104
+ - name: Build wheel + sdist
105
+ run: hatch build
106
+
107
+ - name: Write release notes
108
+ env:
109
+ VERSION: ${{ steps.bump.outputs.version }}
110
+ PREV_VERSION: ${{ steps.bump.outputs.prev_version }}
111
+ RELEASE_NOTE: ${{ github.event.inputs.release_note }}
112
+ run: |
113
+ cat > RELEASE_NOTES.md << EOF
114
+ ## DashControl v${VERSION}
115
+
116
+ **Released:** $(date -u '+%Y-%m-%d')
117
+ **Previous:** v${PREV_VERSION}
118
+
119
+ $( [ -n "${RELEASE_NOTE}" ] && echo "### Notes" && echo "${RELEASE_NOTE}" || true )
120
+
121
+ ### What's included
122
+ - All tests passing across Python 3.9, 3.10, 3.11, 3.12
123
+ - API documentation regenerated (see \`docs/api/\`)
124
+ - Published to PyPI and Databricks Marketplace
125
+
126
+ ### Install
127
+ \`\`\`bash
128
+ pip install dash-control==${VERSION}
129
+ \`\`\`
130
+
131
+ ### Quick Start (Databricks notebook)
132
+ \`\`\`python
133
+ %pip install dash-control==${VERSION}
134
+ import dashcontrol
135
+ dashcontrol.launch()
136
+ \`\`\`
137
+ EOF
138
+
139
+ - name: Commit version bump + docs to a release branch
140
+ env:
141
+ VERSION: ${{ steps.bump.outputs.version }}
142
+ run: |
143
+ git config user.name "github-actions[bot]"
144
+ git config user.email "github-actions[bot]@users.noreply.github.com"
145
+ # Clean up any orphaned tag/branch from a previous partial run
146
+ git push origin --delete "refs/tags/v${VERSION}" 2>/dev/null || true
147
+ git push origin --delete "release/v${VERSION}" 2>/dev/null || true
148
+ git tag -d "v${VERSION}" 2>/dev/null || true
149
+ git checkout -b "release/v${VERSION}"
150
+ git add .
151
+ git commit -m "release: v${VERSION} — tests passed, docs updated"
152
+ git tag "v${VERSION}"
153
+ git push origin "release/v${VERSION}"
154
+ git push origin "v${VERSION}"
155
+
156
+ - name: Create GitHub Release
157
+ uses: softprops/action-gh-release@v2
158
+ with:
159
+ tag_name: "v${{ steps.bump.outputs.version }}"
160
+ body_path: RELEASE_NOTES.md
161
+ files: dist/*
162
+
163
+ - name: Upload dist artifact for PyPI job
164
+ uses: actions/upload-artifact@v4
165
+ with:
166
+ name: dist
167
+ path: dist/
168
+
169
+ - name: Open and auto-merge release PR
170
+ continue-on-error: true
171
+ env:
172
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
173
+ VERSION: ${{ steps.bump.outputs.version }}
174
+ run: |
175
+ gh pr create --base main --head "release/v${VERSION}" \
176
+ --title "release: v${VERSION}" \
177
+ --body "Automated release PR — tests passed, docs regenerated, version bumped to v${VERSION}." \
178
+ 2>/dev/null || true
179
+ gh pr merge "release/v${VERSION}" --merge --admin --delete-branch
180
+
181
+ # ── Publish to PyPI (Trusted Publisher / OIDC — no token needed) ─────────
182
+ publish-pypi:
183
+ name: Publish to PyPI
184
+ runs-on: ubuntu-latest
185
+ needs: release
186
+ permissions:
187
+ id-token: write # required for OIDC trusted publisher
188
+ environment:
189
+ name: pypi
190
+ url: https://pypi.org/project/dash-control
191
+ steps:
192
+ - name: Download dist
193
+ uses: actions/download-artifact@v4
194
+ with:
195
+ name: dist
196
+ path: dist/
197
+
198
+ - name: Publish to PyPI
199
+ uses: pypa/gh-action-pypi-publish@release/v1
200
+
201
+ # ── Package for Databricks Marketplace ───────────────────────────────────
202
+ publish-databricks:
203
+ name: Package for Databricks Marketplace
204
+ runs-on: ubuntu-latest
205
+ needs: release
206
+ steps:
207
+ - uses: actions/checkout@v4
208
+ with:
209
+ ref: "v${{ needs.release.outputs.version }}"
210
+
211
+ - name: Download dist
212
+ uses: actions/download-artifact@v4
213
+ with:
214
+ name: dist
215
+ path: dist/
216
+
217
+ - name: Build Marketplace bundle
218
+ env:
219
+ VERSION: ${{ needs.release.outputs.version }}
220
+ run: |
221
+ mkdir -p marketplace-bundle/files
222
+
223
+ cp dist/*.whl marketplace-bundle/files/
224
+
225
+ cat > marketplace-bundle/files/DashControl_Quickstart.py << NBEOF
226
+ # Databricks notebook source
227
+ # MAGIC %md
228
+ # MAGIC # DashControl v${VERSION} — Databricks Control Center
229
+ # MAGIC Install and launch the interactive UI.
230
+
231
+ # COMMAND ----------
232
+ # MAGIC %pip install dash-control==${VERSION}
233
+
234
+ # COMMAND ----------
235
+ dbutils.library.restartPython()
236
+
237
+ # COMMAND ----------
238
+ import dashcontrol
239
+ dashcontrol.launch()
240
+ NBEOF
241
+
242
+ cat > marketplace-bundle/listing.json << LEOF
243
+ {
244
+ "listing_name": "DashControl — Databricks Control Center",
245
+ "version": "${VERSION}",
246
+ "short_description": "Live system-table dashboard for platform, governance, cost, users, jobs, and queries",
247
+ "long_description": "DashControl provides an ipywidgets UI inside Databricks notebooks to monitor table freshness, row-count volume, and schema changes, persisting results to a Delta history table. No external service to deploy.",
248
+ "categories": ["Data Engineering", "Developer Tools"],
249
+ "tags": ["data-observability", "databricks", "unity-catalog", "pyspark"],
250
+ "provider": "dash-libs",
251
+ "documentation_url": "https://github.com/dash-libs/dash-control",
252
+ "source_url": "https://github.com/dash-libs/dash-control",
253
+ "pypi_package": "dash-control==${VERSION}"
254
+ }
255
+ LEOF
256
+
257
+ cd marketplace-bundle && zip -r ../dashcontrol-marketplace-${VERSION}.zip .
258
+ echo "Bundle created: dashcontrol-marketplace-${VERSION}.zip"
259
+
260
+ - name: Upload Marketplace bundle artifact
261
+ uses: actions/upload-artifact@v4
262
+ with:
263
+ name: marketplace-bundle
264
+ path: dashcontrol-marketplace-*.zip
265
+ retention-days: 90
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: dash-control
3
+ Version: 0.1.1
4
+ Summary: Databricks Control Center — live system-table dashboard for platform, governance, cost, users, jobs, and queries
5
+ Project-URL: Homepage, https://github.com/dash-libs/dash-control
6
+ Author-email: Darshan Shah <darshan.innovation@gmail.com>
7
+ License: Apache-2.0
8
+ Keywords: control-center,cost,dashboard,databricks,governance,observability,system-tables,unity-catalog
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: dash-uis==0.2.3
22
+ Requires-Dist: ipywidgets>=8.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: hatch; extra == 'dev'
25
+ Requires-Dist: pdoc; extra == 'dev'
26
+ Requires-Dist: pytest; extra == 'dev'
27
+ Requires-Dist: pytest-cov; extra == 'dev'
28
+ Requires-Dist: ruff; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # dash-control
32
+
33
+ **Databricks Control Center** — a live, 8-panel system-table dashboard for platform, governance, cost, and operations teams. One `%pip install` + one `launch()` call gives you an always-on view of your entire Databricks workspace.
34
+
35
+ ```python
36
+ %pip install dash-control
37
+ from dashcontrol import launch, ControlCenterConfig
38
+
39
+ config = ControlCenterConfig(
40
+ date_range_days=30,
41
+ catalogs=["main", "analytics"],
42
+ )
43
+ launch(config)
44
+ ```
45
+
46
+ ## Panels
47
+
48
+ | Panel | What it shows |
49
+ |---|---|
50
+ | **Health** | DBU today, active users, failed jobs (last 24 h), total table count, 7-day DBU sparkline |
51
+ | **Cost** | Daily spend by SKU, top clusters, top jobs, top users by DBU, burn-rate projection |
52
+ | **Users** | Top users by query count & tables accessed, inactive users, permission changes |
53
+ | **Catalog** | Table inventory, tables by schema, stale tables, column count distribution, most-accessed tables |
54
+ | **Jobs** | Success rate, top failures, longest runs, daily run volume trend |
55
+ | **Queries** | Slowest queries, most expensive queries, top users, error summary |
56
+ | **Governance** | Tables without owners, PII columns, access anomalies, schema changes |
57
+ | **Custom** | Run any SQL against system tables; define custom panels in `ControlCenterConfig` |
58
+
59
+ ## Configuration
60
+
61
+ ```python
62
+ from dashcontrol import ControlCenterConfig, CustomPanel
63
+
64
+ config = ControlCenterConfig(
65
+ date_range_days=14, # default look-back window (also adjustable in the UI)
66
+ catalogs=["main"], # limit catalog scope; empty = all catalogs
67
+ panels=["health", "cost"], # show only these panels
68
+ row_limit=200, # max rows per table result
69
+ workspace_name="prod", # displayed in the header
70
+ custom_panels=[
71
+ CustomPanel(
72
+ name="My Query",
73
+ sql="SELECT user_name, COUNT(*) AS cnt FROM system.access.audit GROUP BY 1",
74
+ description="Custom audit rollup",
75
+ )
76
+ ],
77
+ )
78
+ ```
79
+
80
+ ## Requirements
81
+
82
+ - Databricks Runtime 13.3 LTS or later (system tables must be enabled)
83
+ - Unity Catalog workspace
84
+ - `ipywidgets` (included as a dependency)
85
+
86
+ ## Architecture
87
+
88
+ Every panel is **lazy-loaded** — clicking Load runs the query; nothing executes on `launch()`. Queries target Databricks system tables (`system.billing.*`, `system.access.audit`, `system.jobs.*`, `system.query.history`, `system.information_schema.*`). All queries degrade gracefully: if a system table isn't available on your workspace tier, the panel shows an info banner instead of crashing.
89
+
90
+ ## Part of the DashLibs suite
91
+
92
+ | Package | Purpose |
93
+ |---|---|
94
+ | [dash-dq](https://pypi.org/project/dash-dq/) | 60+ data quality checks |
95
+ | [dash-synthetic](https://pypi.org/project/dash-synthetic/) | Synthetic data generation |
96
+ | [dash-observe](https://pypi.org/project/dash-observe/) | Freshness, volume & schema monitoring |
97
+ | [dash-gov](https://pypi.org/project/dash-gov/) | Table/column lineage + role classification |
98
+ | [dash-ontology](https://pypi.org/project/dash-ontology/) | Auto-inferred business ontology from lineage |
99
+ | **dash-control** | Control Center — this package |
100
+
101
+ ## License
102
+
103
+ Apache 2.0
@@ -0,0 +1,73 @@
1
+ # dash-control
2
+
3
+ **Databricks Control Center** — a live, 8-panel system-table dashboard for platform, governance, cost, and operations teams. One `%pip install` + one `launch()` call gives you an always-on view of your entire Databricks workspace.
4
+
5
+ ```python
6
+ %pip install dash-control
7
+ from dashcontrol import launch, ControlCenterConfig
8
+
9
+ config = ControlCenterConfig(
10
+ date_range_days=30,
11
+ catalogs=["main", "analytics"],
12
+ )
13
+ launch(config)
14
+ ```
15
+
16
+ ## Panels
17
+
18
+ | Panel | What it shows |
19
+ |---|---|
20
+ | **Health** | DBU today, active users, failed jobs (last 24 h), total table count, 7-day DBU sparkline |
21
+ | **Cost** | Daily spend by SKU, top clusters, top jobs, top users by DBU, burn-rate projection |
22
+ | **Users** | Top users by query count & tables accessed, inactive users, permission changes |
23
+ | **Catalog** | Table inventory, tables by schema, stale tables, column count distribution, most-accessed tables |
24
+ | **Jobs** | Success rate, top failures, longest runs, daily run volume trend |
25
+ | **Queries** | Slowest queries, most expensive queries, top users, error summary |
26
+ | **Governance** | Tables without owners, PII columns, access anomalies, schema changes |
27
+ | **Custom** | Run any SQL against system tables; define custom panels in `ControlCenterConfig` |
28
+
29
+ ## Configuration
30
+
31
+ ```python
32
+ from dashcontrol import ControlCenterConfig, CustomPanel
33
+
34
+ config = ControlCenterConfig(
35
+ date_range_days=14, # default look-back window (also adjustable in the UI)
36
+ catalogs=["main"], # limit catalog scope; empty = all catalogs
37
+ panels=["health", "cost"], # show only these panels
38
+ row_limit=200, # max rows per table result
39
+ workspace_name="prod", # displayed in the header
40
+ custom_panels=[
41
+ CustomPanel(
42
+ name="My Query",
43
+ sql="SELECT user_name, COUNT(*) AS cnt FROM system.access.audit GROUP BY 1",
44
+ description="Custom audit rollup",
45
+ )
46
+ ],
47
+ )
48
+ ```
49
+
50
+ ## Requirements
51
+
52
+ - Databricks Runtime 13.3 LTS or later (system tables must be enabled)
53
+ - Unity Catalog workspace
54
+ - `ipywidgets` (included as a dependency)
55
+
56
+ ## Architecture
57
+
58
+ Every panel is **lazy-loaded** — clicking Load runs the query; nothing executes on `launch()`. Queries target Databricks system tables (`system.billing.*`, `system.access.audit`, `system.jobs.*`, `system.query.history`, `system.information_schema.*`). All queries degrade gracefully: if a system table isn't available on your workspace tier, the panel shows an info banner instead of crashing.
59
+
60
+ ## Part of the DashLibs suite
61
+
62
+ | Package | Purpose |
63
+ |---|---|
64
+ | [dash-dq](https://pypi.org/project/dash-dq/) | 60+ data quality checks |
65
+ | [dash-synthetic](https://pypi.org/project/dash-synthetic/) | Synthetic data generation |
66
+ | [dash-observe](https://pypi.org/project/dash-observe/) | Freshness, volume & schema monitoring |
67
+ | [dash-gov](https://pypi.org/project/dash-gov/) | Table/column lineage + role classification |
68
+ | [dash-ontology](https://pypi.org/project/dash-ontology/) | Auto-inferred business ontology from lineage |
69
+ | **dash-control** | Control Center — this package |
70
+
71
+ ## License
72
+
73
+ Apache 2.0
@@ -0,0 +1,3 @@
1
+ ## DashControl v0.1.0
2
+
3
+ Initial release of the Databricks Control Center.
@@ -0,0 +1,6 @@
1
+ """DashControl — Databricks Control Center."""
2
+ from dashcontrol.config import ControlCenterConfig, CustomPanel
3
+ from dashcontrol.ui import launch
4
+
5
+ __version__ = "0.1.1"
6
+ __all__ = ["ControlCenterConfig", "CustomPanel", "launch"]
@@ -0,0 +1,62 @@
1
+ """
2
+ ControlCenterConfig — user-facing configuration for the Control Center.
3
+
4
+ All fields have sensible defaults so teams can call launch() with zero config,
5
+ or pre-wire a domain-specific dashboard by passing a config object.
6
+ """
7
+ from __future__ import annotations
8
+ from dataclasses import dataclass, field
9
+
10
+ ALL_PANELS = ["health", "cost", "users", "catalog", "jobs", "queries", "governance"]
11
+
12
+
13
+ @dataclass
14
+ class CustomPanel:
15
+ title: str
16
+ sql: str
17
+ description: str = ""
18
+
19
+
20
+ @dataclass
21
+ class ControlCenterConfig:
22
+ """
23
+ Configuration for the Databricks Control Center.
24
+
25
+ Parameters
26
+ ----------
27
+ date_range_days
28
+ Default lookback window for all time-based queries (default: 30).
29
+ catalogs
30
+ List of catalogs to scope table/lineage queries to.
31
+ Empty list means all visible catalogs.
32
+ panels
33
+ Which built-in panels to show. Defaults to all.
34
+ custom_panels
35
+ User-defined SQL panels appended after the built-in tabs.
36
+ row_limit
37
+ Max rows returned per panel query (default: 500).
38
+ workspace_name
39
+ Optional display name shown in the dashboard header.
40
+ """
41
+ date_range_days: int = 30
42
+ catalogs: list[str] = field(default_factory=list)
43
+ panels: list[str] = field(default_factory=lambda: list(ALL_PANELS))
44
+ custom_panels: list[CustomPanel] = field(default_factory=list)
45
+ row_limit: int = 500
46
+ workspace_name: str = ""
47
+
48
+ def __post_init__(self):
49
+ unknown = set(self.panels) - set(ALL_PANELS)
50
+ if unknown:
51
+ raise ValueError(f"Unknown panels: {unknown}. Valid: {ALL_PANELS}")
52
+ if self.date_range_days < 1 or self.date_range_days > 365:
53
+ raise ValueError("date_range_days must be between 1 and 365")
54
+ if self.row_limit < 1 or self.row_limit > 10_000:
55
+ raise ValueError("row_limit must be between 1 and 10,000")
56
+
57
+ def catalog_filter(self, col: str = "table_catalog") -> str:
58
+ """SQL WHERE fragment to filter by configured catalogs."""
59
+ if not self.catalogs:
60
+ return ""
61
+ quoted = ", ".join(f"'{c}'" for c in self.catalogs)
62
+ return f"AND {col} IN ({quoted})"
@@ -0,0 +1,161 @@
1
+ """
2
+ Pure-Python formatting utilities — no Spark dependency.
3
+
4
+ Converts query result rows (list[dict]) into styled HTML tables,
5
+ stat tiles, trend sparklines, and CSV exports for the Control Center UI.
6
+ """
7
+ from __future__ import annotations
8
+ from typing import Optional
9
+
10
+ # Colour palette
11
+ _TEAL = "#2A9D90"
12
+ _AMBER = "#F4A261"
13
+ _RED = "#E63946"
14
+ _GREEN = "#2DC653"
15
+ _GREY = "#6B7280"
16
+ _LIGHT = "#F9FAFB"
17
+ _BORDER = "#E5E7EB"
18
+
19
+
20
+ def stat_tile(label: str, value, color: str = _TEAL, unit: str = "") -> str:
21
+ """Render a single KPI tile (label + big number)."""
22
+ return (
23
+ f"<div style='display:inline-block;padding:14px 20px;margin:6px;"
24
+ f"border-radius:8px;background:{_LIGHT};border:1px solid {_BORDER};"
25
+ f"min-width:140px;text-align:center'>"
26
+ f"<div style='font-size:26px;font-weight:700;color:{color}'>"
27
+ f"{value}{unit}</div>"
28
+ f"<div style='font-size:11px;color:{_GREY};margin-top:4px'>{label}</div>"
29
+ f"</div>"
30
+ )
31
+
32
+
33
+ def stat_row(tiles: list[str]) -> str:
34
+ """Wrap stat tiles in a flex row."""
35
+ inner = "".join(tiles)
36
+ return f"<div style='display:flex;flex-wrap:wrap;gap:4px;margin-bottom:12px'>{inner}</div>"
37
+
38
+
39
+ def html_table(
40
+ rows: list[dict],
41
+ highlight_col: Optional[str] = None,
42
+ max_rows: int = 200,
43
+ col_widths: Optional[dict] = None,
44
+ ) -> str:
45
+ """
46
+ Convert a list of row-dicts to a styled HTML table.
47
+
48
+ highlight_col — column whose values determine row background colour
49
+ (high numeric value = amber, error strings = red)
50
+ """
51
+ if not rows:
52
+ return "<div style='color:#9ca3af;font-size:12px;padding:8px'>No data</div>"
53
+
54
+ cols = list(rows[0].keys())
55
+ col_widths = col_widths or {}
56
+
57
+ def _header(col: str) -> str:
58
+ w = f"min-width:{col_widths[col]};" if col in col_widths else ""
59
+ label = col.replace("_", " ").title()
60
+ return (
61
+ f"<th style='padding:6px 10px;background:#F3F4F6;text-align:left;"
62
+ f"font-size:11px;color:{_GREY};font-weight:600;white-space:nowrap;{w}'>"
63
+ f"{label}</th>"
64
+ )
65
+
66
+ def _cell(val, col: str) -> str:
67
+ text = "" if val is None else str(val)
68
+ # Truncate long strings
69
+ display = text[:80] + "…" if len(text) > 80 else text
70
+ return (
71
+ f"<td style='padding:5px 10px;font-size:12px;"
72
+ f"font-family:monospace;border-top:1px solid {_BORDER}'>"
73
+ f"{display}</td>"
74
+ )
75
+
76
+ def _row_bg(row: dict) -> str:
77
+ if highlight_col and highlight_col in row:
78
+ v = row[highlight_col]
79
+ if isinstance(v, str) and any(k in v.upper() for k in ("FAIL", "ERROR", "DENIED")):
80
+ return "background:#FEF2F2;"
81
+ return ""
82
+
83
+ headers = "".join(_header(c) for c in cols)
84
+ body_rows = ""
85
+ for r in rows[:max_rows]:
86
+ bg = _row_bg(r)
87
+ cells = "".join(_cell(r.get(c), c) for c in cols)
88
+ body_rows += f"<tr style='{bg}'>{cells}</tr>"
89
+
90
+ footer = ""
91
+ if len(rows) > max_rows:
92
+ footer = (
93
+ f"<tr><td colspan='{len(cols)}' style='padding:6px 10px;"
94
+ f"font-size:11px;color:{_GREY};text-align:center'>"
95
+ f"Showing {max_rows} of {len(rows)} rows</td></tr>"
96
+ )
97
+
98
+ return (
99
+ f"<div style='overflow-x:auto'>"
100
+ f"<table style='border-collapse:collapse;width:100%;font-size:12px'>"
101
+ f"<thead><tr>{headers}</tr></thead>"
102
+ f"<tbody>{body_rows}{footer}</tbody>"
103
+ f"</table></div>"
104
+ )
105
+
106
+
107
+ def error_box(message: str) -> str:
108
+ """Red alert box for query errors."""
109
+ return (
110
+ f"<div style='padding:10px 14px;background:#FEF2F2;border:1px solid #FCA5A5;"
111
+ f"border-radius:6px;color:{_RED};font-size:12px;font-family:monospace'>"
112
+ f"⚠ {message}</div>"
113
+ )
114
+
115
+
116
+ def info_box(message: str) -> str:
117
+ """Blue info box."""
118
+ return (
119
+ f"<div style='padding:10px 14px;background:#EFF6FF;border:1px solid #BFDBFE;"
120
+ f"border-radius:6px;color:#1D4ED8;font-size:12px'>"
121
+ f"ℹ {message}</div>"
122
+ )
123
+
124
+
125
+ def section_header(title: str, subtitle: str = "") -> str:
126
+ sub = f"<span style='font-size:11px;color:{_GREY};margin-left:8px'>{subtitle}</span>" if subtitle else ""
127
+ return (
128
+ f"<div style='font-weight:600;font-size:13px;color:#374151;"
129
+ f"margin:12px 0 6px;padding-bottom:4px;border-bottom:1px solid {_BORDER}'>"
130
+ f"{title}{sub}</div>"
131
+ )
132
+
133
+
134
+ def sparkline_html(values: list[float], label: str = "") -> str:
135
+ """
136
+ Minimal ASCII sparkline for trend data (no JS/SVG required).
137
+ Uses Unicode block characters: ▁▂▃▄▅▆▇█
138
+ """
139
+ blocks = "▁▂▃▄▅▆▇█"
140
+ if not values or all(v == 0 for v in values):
141
+ return f"<span style='font-family:monospace;color:{_GREY}'>{'▁' * 10}</span>"
142
+ mn, mx = min(values), max(values)
143
+ rng = mx - mn or 1
144
+ chars = "".join(blocks[min(7, int((v - mn) / rng * 7))] for v in values)
145
+ return (
146
+ f"<span style='font-family:monospace;font-size:14px;color:{_TEAL}'>{chars}</span>"
147
+ + (f"<span style='font-size:10px;color:{_GREY};margin-left:4px'>{label}</span>" if label else "")
148
+ )
149
+
150
+
151
+ def format_number(n) -> str:
152
+ """Format large numbers with K/M suffixes."""
153
+ try:
154
+ n = float(n)
155
+ except (TypeError, ValueError):
156
+ return str(n)
157
+ if abs(n) >= 1_000_000:
158
+ return f"{n / 1_000_000:.1f}M"
159
+ if abs(n) >= 1_000:
160
+ return f"{n / 1_000:.1f}K"
161
+ return f"{n:,.0f}" if n == int(n) else f"{n:,.2f}"