mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(dashboards): v2.7 widget catalog — file-reading widgets, sparkline, typed status, project-wide watch
Major project-dashboard release. Five new widget types (markdown_file, log_tail, cron_status, image, status_grid), inline sparkline on stat, typed status enum shared by list + status_grid, structured WidgetErrorCard, and a project-wide .scarf/ directory watch that picks up files cron jobs write next to dashboard.json. - ProjectDashboard: extend DashboardWidget with path/lines/jobId/cells/gridColumns/sparkline; add StatusGridCell + ListItemStatus (lenient parse with synonyms) - HermesFileWatcher: watch each project's .scarf/ dir alongside dashboard.json (local FSEvents + remote SSH mtime poll); updateProjectWatches signature now takes dashboardPaths + scarfDirs - New widget views: CronStatus, Image, LogTail, MarkdownFile, StatusGrid, plus WidgetErrorCard for structured failure messaging; legacy "Unknown" placeholder replaced everywhere - WidgetPathResolver: project-root-anchored path resolution that rejects absolute paths + ".." escapes pre and post canonicalization - Stat widget gains optional inline sparkline (pure SwiftUI Path, no Charts dep); list widget rows route through typed status with semantic icons + ScarfColor tints - iOS list widget + unsupported card adopt typed status + warning-toned error card (parity with Mac error styling); new widget types remain Mac-only - Site mirror: widgets.js renders all five new types (file-reading widgets show annotated catalog placeholders), sparkline SVG, status-grid grid; styles.css adds typed-status palette + error-card + sparkline + grid styles - Catalog validator: tools/widget-schema.json is the single source of truth; build-catalog.py loads it and enforces per-type required fields. 8 new test cases in test_build_catalog.py covering schema load, v2.7 additions, and missing-required rejection - Template-author skill (SKILL.md) gains v2.7 Widget Catalog section + canonical status guidance; CONTRIBUTING.md points authors at widget-schema.json; template-author bundle rebuilt - Localizable.xcstrings picks up auto-extracted strings for the previously-shipped OAuth keepalive feature - Release notes drafted at releases/v2.7.0/RELEASE_NOTES.md Backwards compatible — existing dashboard.json renders byte-identically, status synonyms (ok/up/down/active/etc.) keep working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+29
-1
@@ -56,7 +56,24 @@ SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2, SCHEMA_VERSIO
|
||||
SLASH_COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
MAX_BUNDLE_BYTES = 5 * 1024 * 1024 # 5 MB cap on submissions; installer is 50 MB
|
||||
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
|
||||
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
|
||||
# Widget vocabulary — loaded from tools/widget-schema.json (single source of
|
||||
# truth, also referenced by the agent-authoring SKILL.md). Each entry has
|
||||
# `required` + `optional` field name lists. Adding a widget type means
|
||||
# editing widget-schema.json + implementing the Swift view + the JS
|
||||
# renderer; this file picks up the additions automatically.
|
||||
def _load_widget_schema() -> dict:
|
||||
schema_path = Path(__file__).resolve().parent / "widget-schema.json"
|
||||
with schema_path.open("r", encoding="utf-8") as f:
|
||||
schema = json.load(f)
|
||||
if schema.get("schemaVersion") != 1:
|
||||
raise SystemExit(f"unsupported widget-schema version: {schema.get('schemaVersion')}")
|
||||
widgets = schema.get("widgets") or {}
|
||||
if not isinstance(widgets, dict) or not widgets:
|
||||
raise SystemExit("widget-schema.json: 'widgets' must be a non-empty object")
|
||||
return widgets
|
||||
|
||||
WIDGET_SCHEMA = _load_widget_schema()
|
||||
SUPPORTED_WIDGET_TYPES = set(WIDGET_SCHEMA.keys())
|
||||
|
||||
# Mirror of Swift's TemplateConfigField.FieldType. Order matters only
|
||||
# for error messages that echo this set.
|
||||
@@ -423,6 +440,17 @@ def _validate_dashboard(zf: zipfile.ZipFile, template_dir: Path, errors: list[Va
|
||||
template_dir,
|
||||
f"dashboard widget {widget.get('title')!r} has unknown type {widget_type!r}"
|
||||
))
|
||||
continue
|
||||
spec = WIDGET_SCHEMA[widget_type]
|
||||
for required_field in spec.get("required", []):
|
||||
if required_field == "title":
|
||||
continue # validated implicitly by the title in the error message
|
||||
if widget.get(required_field) in (None, "", []):
|
||||
errors.append(ValidationError(
|
||||
template_dir,
|
||||
f"dashboard widget {widget.get('title')!r} (type {widget_type!r}) "
|
||||
f"missing required field {required_field!r}"
|
||||
))
|
||||
|
||||
|
||||
def _scan_for_secrets(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||
|
||||
@@ -266,6 +266,174 @@ class ValidationTests(unittest.TestCase):
|
||||
_, errors = self._validate_all()
|
||||
self.assertTrue(any("unknown type" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_widget_missing_required_field(self):
|
||||
# 'progress' requires both title + value; omit value.
|
||||
bad_dashboard = {
|
||||
"version": 1,
|
||||
"title": "Bad",
|
||||
"sections": [{"title": "x", "columns": 1, "widgets": [
|
||||
{"type": "progress", "title": "Loading"},
|
||||
]}],
|
||||
}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/missing-required",
|
||||
"name": "Missing",
|
||||
"version": "1.0.0",
|
||||
"description": "missing required field",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "missing-required",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
_, errors = self._validate_all()
|
||||
self.assertTrue(
|
||||
any("missing required field 'value'" in str(e) for e in errors),
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_widget_schema_loads_and_lists_known_types(self):
|
||||
# Sanity check: schema includes the v2.2 originals so old templates
|
||||
# keep validating.
|
||||
for t in ("stat", "progress", "text", "table", "chart", "list", "webview"):
|
||||
self.assertIn(t, build_catalog.SUPPORTED_WIDGET_TYPES)
|
||||
|
||||
def test_widget_schema_includes_v2_7_additions(self):
|
||||
# v2.7 added markdown_file, log_tail, cron_status, image, status_grid.
|
||||
for t in ("markdown_file", "log_tail", "cron_status", "image", "status_grid"):
|
||||
self.assertIn(t, build_catalog.SUPPORTED_WIDGET_TYPES)
|
||||
|
||||
def test_v2_7_widgets_accept_canonical_minimum_fields(self):
|
||||
# Build one bundle whose dashboard exercises every v2.7 addition with
|
||||
# its canonical required fields populated. If any per-type rule
|
||||
# over-tightens, this test catches it before catalog publishing.
|
||||
ok_dashboard = {
|
||||
"version": 1,
|
||||
"title": "v2.7 sampler",
|
||||
"sections": [{
|
||||
"title": "Sample",
|
||||
"columns": 2,
|
||||
"widgets": [
|
||||
{"type": "stat", "title": "Sites", "value": 4, "sparkline": [1, 2, 3, 2, 4]},
|
||||
{"type": "list", "title": "Status",
|
||||
"items": [{"text": "auth.example.com", "status": "ok"},
|
||||
{"text": "api.example.com", "status": "down"}]},
|
||||
{"type": "markdown_file", "title": "Weekly", "path": "reports/weekly.md"},
|
||||
{"type": "log_tail", "title": "Tail", "path": "reports/run.log", "lines": 30},
|
||||
{"type": "cron_status", "title": "Job", "jobId": "uptime-sweep"},
|
||||
{"type": "image", "title": "Pic", "path": "reports/chart.png"},
|
||||
{"type": "status_grid", "title": "Fleet", "cells": [
|
||||
{"label": "us-east-1", "status": "success"},
|
||||
{"label": "us-west-2", "status": "warning"},
|
||||
{"label": "eu-central-1", "status": "danger"},
|
||||
]},
|
||||
],
|
||||
}],
|
||||
}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/v2_7",
|
||||
"name": "v2.7 sampler",
|
||||
"version": "1.0.0",
|
||||
"description": "exercises every v2.7 widget",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "v2_7",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(ok_dashboard).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
templates, errors = self._validate_all()
|
||||
self.assertEqual(errors, [], f"unexpected errors: {errors}")
|
||||
|
||||
def test_v2_7_widgets_reject_missing_required(self):
|
||||
# Each v2.7 file/cron/grid widget has a required field. A bundle that
|
||||
# omits any of them should be rejected.
|
||||
bad_dashboard = {
|
||||
"version": 1,
|
||||
"title": "Bad sampler",
|
||||
"sections": [{
|
||||
"title": "Bad",
|
||||
"columns": 1,
|
||||
"widgets": [
|
||||
{"type": "markdown_file", "title": "no path"},
|
||||
{"type": "log_tail", "title": "no path"},
|
||||
{"type": "cron_status", "title": "no jobId"},
|
||||
{"type": "status_grid", "title": "no cells"},
|
||||
],
|
||||
}],
|
||||
}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/v2_7_bad",
|
||||
"name": "Bad",
|
||||
"version": "1.0.0",
|
||||
"description": "missing required fields",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "v2_7_bad",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
_, errors = self._validate_all()
|
||||
# Expect at least one missing-required error per offending widget.
|
||||
for required, label in [("path", "markdown_file"), ("path", "log_tail"),
|
||||
("jobId", "cron_status"), ("cells", "status_grid")]:
|
||||
self.assertTrue(
|
||||
any(f"missing required field '{required}'" in str(e) and label in str(e) for e in errors),
|
||||
f"expected missing `{required}` error for {label}; got: {errors}",
|
||||
)
|
||||
|
||||
def test_cron_status_requires_jobId(self):
|
||||
bad_dashboard = {
|
||||
"version": 1,
|
||||
"title": "Bad",
|
||||
"sections": [{"title": "x", "columns": 1, "widgets": [
|
||||
{"type": "cron_status", "title": "Without jobId"},
|
||||
]}],
|
||||
}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/cron-no-id",
|
||||
"name": "Cron",
|
||||
"version": "1.0.0",
|
||||
"description": "missing jobId",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "cron-no-id",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
_, errors = self._validate_all()
|
||||
self.assertTrue(
|
||||
any("missing required field 'jobId'" in str(e) for e in errors),
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_rejects_secret_in_bundle(self):
|
||||
leaky = b"config:\n github_token: ghp_" + b"A" * 40 + b"\n"
|
||||
manifest = {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"comment": "Canonical project-dashboard widget vocabulary. Single source of truth for the catalog validator (tools/build-catalog.py) and the agent-authoring skill (templates/awizemann/template-author/staging/skills/scarf-template-author/SKILL.md). The Swift renderer (scarf/scarf/Features/Projects/Views/ProjectsView.swift WidgetView) and the JS renderer (site/widgets.js) are hand-written but MUST stay aligned with the type list here. Adding a new widget type: add it here first, then implement the Swift view + JS renderer, then update the SKILL.md Widget Catalog section. Removing or renaming a type breaks every dashboard.json that uses it — don't.",
|
||||
"widgets": {
|
||||
"stat": {
|
||||
"description": "Single big-number metric with optional icon, subtitle, color, and inline sparkline trend.",
|
||||
"since": "v2.2",
|
||||
"required": ["title"],
|
||||
"optional": ["value", "icon", "color", "subtitle", "sparkline"]
|
||||
},
|
||||
"progress": {
|
||||
"description": "0.0..1.0 horizontal progress bar with optional label.",
|
||||
"since": "v2.2",
|
||||
"required": ["title", "value"],
|
||||
"optional": ["label", "color"]
|
||||
},
|
||||
"text": {
|
||||
"description": "Inline text or markdown block. Use 'markdown_file' if the content lives in an external file.",
|
||||
"since": "v2.2",
|
||||
"required": ["title", "content"],
|
||||
"optional": ["format"]
|
||||
},
|
||||
"table": {
|
||||
"description": "Columns x rows of strings.",
|
||||
"since": "v2.2",
|
||||
"required": ["title", "columns", "rows"],
|
||||
"optional": []
|
||||
},
|
||||
"chart": {
|
||||
"description": "Line / bar / area / pie chart over named series.",
|
||||
"since": "v2.2",
|
||||
"required": ["title", "series"],
|
||||
"optional": ["chartType", "xLabel", "yLabel"]
|
||||
},
|
||||
"list": {
|
||||
"description": "Bulleted list with optional typed status badges per item (success / warning / danger / info / pending / done / neutral; unknown values render as plain text).",
|
||||
"since": "v2.2",
|
||||
"required": ["title", "items"],
|
||||
"optional": []
|
||||
},
|
||||
"webview": {
|
||||
"description": "Embedded URL in an iframe / WKWebView. Including a webview also exposes a Site tab in the project view.",
|
||||
"since": "v2.2",
|
||||
"required": ["title", "url"],
|
||||
"optional": ["height"]
|
||||
},
|
||||
"markdown_file": {
|
||||
"description": "Renders a markdown file from disk, relative to the project root. Refreshes when any file under the project's .scarf/ directory changes.",
|
||||
"since": "v2.7",
|
||||
"required": ["title", "path"],
|
||||
"optional": []
|
||||
},
|
||||
"log_tail": {
|
||||
"description": "Tails the last N lines of a file from disk, monospaced. Useful for surfacing the most recent cron-job output. Strips ANSI color codes.",
|
||||
"since": "v2.7",
|
||||
"required": ["title", "path"],
|
||||
"optional": ["lines"]
|
||||
},
|
||||
"cron_status": {
|
||||
"description": "Last run / next run / current state for one Hermes cron job by id, with a tiny inline log tail.",
|
||||
"since": "v2.7",
|
||||
"required": ["title", "jobId"],
|
||||
"optional": ["lines"]
|
||||
},
|
||||
"image": {
|
||||
"description": "Local image file (path relative to project root) or remote URL.",
|
||||
"since": "v2.7",
|
||||
"required": ["title"],
|
||||
"optional": ["path", "url", "height"]
|
||||
},
|
||||
"status_grid": {
|
||||
"description": "Compact grid of colored cells, one per service / item, with hover labels. Reuses the typed status enum.",
|
||||
"since": "v2.7",
|
||||
"required": ["title", "cells"],
|
||||
"optional": ["columns"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user