mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +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:
@@ -0,0 +1,61 @@
|
|||||||
|
## What's in 2.7.0
|
||||||
|
|
||||||
|
A focused release on **project dashboards** — the most "live" surface for users running cron-driven workflows. This release does three things at once:
|
||||||
|
|
||||||
|
1. **Auto-refresh now covers the entire project, not just `dashboard.json`.** A widget that points at `<project>/.scarf/reports/uptime.md` refreshes the moment the cron job rewrites it.
|
||||||
|
2. **Five new widget types** make cron-driven monitoring dashboards much more expressive — render markdown reports from disk, tail log files, surface Hermes cron-job state, embed images, and pack many services into a compact status grid.
|
||||||
|
3. **`stat` widgets gain inline sparklines.** `list` items get a typed status enum with semantic colors. Unknown widget types render as a structured error card (not a generic "Unknown" placeholder).
|
||||||
|
|
||||||
|
**Backwards compatible — no schema bump.** Every existing `dashboard.json` renders byte-identically on v2.7. The catalog manifest format is unchanged. v1, v2, v3 bundles install identically as before. Templates that adopt new widget types still validate against the existing manifest schema — only the catalog validator's vocabulary list was extended.
|
||||||
|
|
||||||
|
### Project-wide auto-refresh
|
||||||
|
|
||||||
|
[`HermesFileWatcher`](../../scarf/scarf/Core/Services/HermesFileWatcher.swift) used to watch each project's `dashboard.json` file specifically. v2.7 promotes that to a watch on the entire `<project>/.scarf/` directory:
|
||||||
|
|
||||||
|
- **Local** — adds a `DispatchSourceFileSystemObject` per project's `.scarf/` dir alongside the existing per-file watch on `dashboard.json`.
|
||||||
|
- **Remote (SSH)** — folds project `.scarf/` directories into the existing 3-second mtime poll. Closes the explicit "Phase 4 polish item" deferral that landed in v2.3.
|
||||||
|
|
||||||
|
Effect: a `markdown_file` or `log_tail` widget pointing at `<project>/.scarf/reports/foo.md` refreshes automatically when a cron job rewrites the file. **By convention, place files the dashboard reads inside `.scarf/`** so the watch picks them up. Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes.
|
||||||
|
|
||||||
|
_Limitation:_ in-place appends to an existing file (`>> file.log`) don't tick the watcher — the cron job should write atomically (write-temp + rename), or `touch dashboard.json` after each run to force a refresh. Per-widget data-source watching (the granular alternative) is deferred to a future release; this project-wide pattern covers the common cron-driven workflow without the extra plumbing.
|
||||||
|
|
||||||
|
### Five new widget types
|
||||||
|
|
||||||
|
All five additive — pre-v2.7 Scarf renders unknown widget types as a clearly-labeled error card now (not a crash). They share two conventions: file paths are resolved relative to the project root with a hard `..`-escape rejection at [`WidgetPathResolver`](../../scarf/scarf/Features/Projects/Views/Widgets/WidgetPathResolver.swift), and reads happen in `Task.detached` so dashboards never block the main actor.
|
||||||
|
|
||||||
|
- **`markdown_file`** — renders a markdown file from disk through the same `MarkdownContentView` pipeline used by inline `text` widgets. Pair with cron jobs that write longer-form reports.
|
||||||
|
- **`log_tail`** — last `lines` of a file (default 20, max 200), monospaced, ANSI codes stripped. Killer for "what did my cron job print last run?".
|
||||||
|
- **`cron_status`** — last run / next run / state for one Hermes cron job by `jobId`, plus a small inline log tail. Read-only — Run / Pause / Resume controls stay on the Cron tab so the dashboard isn't a place where you accidentally fire a job.
|
||||||
|
- **`image`** — local file (`path` relative to project root, via `transport.readFile`) or remote `url` (via `AsyncImage`). Optional `height` cap. Useful for matplotlib/Plotly PNGs the cron job generates.
|
||||||
|
- **`status_grid`** — compact NxM grid of colored cells, one per service / item, with hover labels. Reuses the typed status enum so colors stay consistent with `list` widgets.
|
||||||
|
|
||||||
|
### `stat` widget gains inline sparklines
|
||||||
|
|
||||||
|
`stat` widgets now accept an optional `sparkline: [Number]` field — a tiny inline trend line under the big number. SVG-only render, no Chart.js dependency, dozens per dashboard cost nothing. Old `stat` widgets without the field render exactly as before.
|
||||||
|
|
||||||
|
### Typed status badges (lenient decode)
|
||||||
|
|
||||||
|
`list` items and `status_grid` cells share a typed status enum: `success`, `warning`, `danger`, `info`, `pending`, `done`, `neutral`. Common synonyms map to the canonical case (`ok` / `up` → success, `down` / `error` / `failed` → danger, `active` → info, `complete` → done). Unknown strings render as plain text rather than crashing — the dev's machine alone has dashboards using ad-hoc statuses like `"ok"`, `"up"`, `"info"`, and they all keep working byte-identically. **For new templates, prefer the canonical names** so colors stay predictable across releases.
|
||||||
|
|
||||||
|
### Structured widget error card
|
||||||
|
|
||||||
|
The legacy "Unknown: \<type\>" placeholder is replaced with a structured error card surfacing the widget's title, the specific reason (unknown type, missing file, parse error, path escapes project root), and a hint. Used by the dispatcher's default branch and by every v2.7 file-reading widget when its underlying data can't be loaded.
|
||||||
|
|
||||||
|
### Schema mirror — single source of truth
|
||||||
|
|
||||||
|
The widget vocabulary is now defined once at [`tools/widget-schema.json`](../../tools/widget-schema.json) instead of being maintained in three places by hand. The catalog validator ([`tools/build-catalog.py`](../../tools/build-catalog.py)) reads from it and now enforces per-type required fields (e.g. `cron_status` requires `jobId`, `log_tail` requires `path`). Adding a future widget type means editing one JSON file plus implementing a Swift view + a JS renderer; the validator picks up the addition automatically.
|
||||||
|
|
||||||
|
### What's deferred
|
||||||
|
|
||||||
|
Two items from the design plan stayed deferred:
|
||||||
|
|
||||||
|
- **Per-widget data sources + per-widget refresh granularity.** The general "widget points at a typed data source (file / cron / json-path / …)" abstraction is the next-largest win in this area but materially expands the model + JS mirror + validator + authoring skill surface. The project-wide watch covers the common cron-driven workflow without it; revisit when a real-world template wants the granular control.
|
||||||
|
- **Cross-project health digest sidebar rollup.** Counting attention-needed projects across the registry was scoped for this release but ended up not pulling its weight against the rest of the work; the underlying status enum (B.1) makes a future digest cheap to add.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- macOS 14+ (unchanged).
|
||||||
|
- Hermes target: still **v2026.4.30 (v0.12.0)**. No new Hermes capability gates added.
|
||||||
|
- Existing `dashboard.json` files render unchanged.
|
||||||
|
- Existing `.scarftemplate` bundles install unchanged. Catalog manifest schemaVersion stays at 1/2/3 — no bump.
|
||||||
|
- The `awizemann/template-author` bundle was rebuilt to ship the updated `SKILL.md` (Widget Catalog v2.7+ section) so Hermes can scaffold dashboards using the new widget types out of the box.
|
||||||
@@ -39,6 +39,13 @@ public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
|||||||
|
|
||||||
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
||||||
|
|
||||||
|
/// Directory holding the project's Scarf-managed sidecar files
|
||||||
|
/// (dashboard.json, manifest.json, template.lock.json, config.json,
|
||||||
|
/// plus any cron-job-written reports the dashboard widgets reference).
|
||||||
|
/// Watched as a unit by `HermesFileWatcher` so any file added /
|
||||||
|
/// removed / renamed inside refreshes the dashboard automatically.
|
||||||
|
public var scarfDir: String { path + "/.scarf" }
|
||||||
|
|
||||||
// MARK: - Codable (custom for backward compat)
|
// MARK: - Codable (custom for backward compat)
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
@@ -152,29 +159,54 @@ public struct DashboardWidget: Codable, Sendable, Identifiable {
|
|||||||
// List
|
// List
|
||||||
public let items: [ListItem]?
|
public let items: [ListItem]?
|
||||||
|
|
||||||
// Webview
|
// Webview / Image (image reuses `url` for remote, `path` for local)
|
||||||
public let url: String?
|
public let url: String?
|
||||||
public let height: Double?
|
public let height: Double?
|
||||||
|
|
||||||
|
// v2.7 — file-reading widgets (markdown_file, log_tail, image-local).
|
||||||
|
// `path` is resolved relative to the project root (the directory that
|
||||||
|
// contains `.scarf/`). Renderers must reject `..` segments after
|
||||||
|
// normalization to prevent escape from the project boundary.
|
||||||
|
public let path: String?
|
||||||
|
public let lines: Int?
|
||||||
|
|
||||||
|
// v2.7 — cron_status widget; `jobId` matches HermesCronJob.id.
|
||||||
|
public let jobId: String?
|
||||||
|
|
||||||
|
// v2.7 — status_grid widget; `cells` carries label + status per square,
|
||||||
|
// `gridColumns` overrides the auto-fit column count (keep distinct
|
||||||
|
// from `columns` which is the table-widget header list).
|
||||||
|
public let cells: [StatusGridCell]?
|
||||||
|
public let gridColumns: Int?
|
||||||
|
|
||||||
|
// v2.7 — optional sparkline trend on `stat` widgets.
|
||||||
|
public let sparkline: [Double]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
type: String,
|
type: String,
|
||||||
title: String,
|
title: String,
|
||||||
value: WidgetValue?,
|
value: WidgetValue? = nil,
|
||||||
icon: String?,
|
icon: String? = nil,
|
||||||
color: String?,
|
color: String? = nil,
|
||||||
subtitle: String?,
|
subtitle: String? = nil,
|
||||||
label: String?,
|
label: String? = nil,
|
||||||
content: String?,
|
content: String? = nil,
|
||||||
format: String?,
|
format: String? = nil,
|
||||||
columns: [String]?,
|
columns: [String]? = nil,
|
||||||
rows: [[String]]?,
|
rows: [[String]]? = nil,
|
||||||
chartType: String?,
|
chartType: String? = nil,
|
||||||
xLabel: String?,
|
xLabel: String? = nil,
|
||||||
yLabel: String?,
|
yLabel: String? = nil,
|
||||||
series: [ChartSeries]?,
|
series: [ChartSeries]? = nil,
|
||||||
items: [ListItem]?,
|
items: [ListItem]? = nil,
|
||||||
url: String?,
|
url: String? = nil,
|
||||||
height: Double?
|
height: Double? = nil,
|
||||||
|
path: String? = nil,
|
||||||
|
lines: Int? = nil,
|
||||||
|
jobId: String? = nil,
|
||||||
|
cells: [StatusGridCell]? = nil,
|
||||||
|
gridColumns: Int? = nil,
|
||||||
|
sparkline: [Double]? = nil
|
||||||
) {
|
) {
|
||||||
self.type = type
|
self.type = type
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -194,6 +226,29 @@ public struct DashboardWidget: Codable, Sendable, Identifiable {
|
|||||||
self.items = items
|
self.items = items
|
||||||
self.url = url
|
self.url = url
|
||||||
self.height = height
|
self.height = height
|
||||||
|
self.path = path
|
||||||
|
self.lines = lines
|
||||||
|
self.jobId = jobId
|
||||||
|
self.cells = cells
|
||||||
|
self.gridColumns = gridColumns
|
||||||
|
self.sparkline = sparkline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status Grid Data (v2.7)
|
||||||
|
|
||||||
|
/// One cell of a `status_grid` widget. Status semantics match `ListItem.status`
|
||||||
|
/// — parsed via `ListItemStatus(raw:)` so the same vocabulary + synonyms apply.
|
||||||
|
public struct StatusGridCell: Codable, Sendable, Identifiable, Hashable {
|
||||||
|
public var id: String { label }
|
||||||
|
public let label: String
|
||||||
|
public let status: String?
|
||||||
|
public let tooltip: String?
|
||||||
|
|
||||||
|
public init(label: String, status: String? = nil, tooltip: String? = nil) {
|
||||||
|
self.label = label
|
||||||
|
self.status = status
|
||||||
|
self.tooltip = tooltip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,3 +339,47 @@ public struct ListItem: Codable, Sendable, Identifiable {
|
|||||||
self.status = status
|
self.status = status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Typed semantic status for `ListItem` (and `status_grid` cells in v2.7+).
|
||||||
|
///
|
||||||
|
/// Wire format stays a free `String?` on `ListItem` for backwards compatibility —
|
||||||
|
/// pre-existing dashboards never break. Renderers call `ListItemStatus(raw:)`
|
||||||
|
/// to map known values + synonyms to a canonical case; unknown values return
|
||||||
|
/// `nil` and render as plain neutral text.
|
||||||
|
public enum ListItemStatus: String, Sendable, Hashable, CaseIterable {
|
||||||
|
case success
|
||||||
|
case warning
|
||||||
|
case danger
|
||||||
|
case info
|
||||||
|
case pending
|
||||||
|
case done
|
||||||
|
case neutral
|
||||||
|
|
||||||
|
/// Lenient parse — accepts canonical names plus common synonyms seen in
|
||||||
|
/// real-world dashboards (`ok`/`up` → success, `down`/`error`/`failed` →
|
||||||
|
/// danger, `active` → info). Returns `nil` for unrecognized strings so
|
||||||
|
/// the renderer can fall back to plain text.
|
||||||
|
public init?(raw: String?) {
|
||||||
|
guard let raw = raw?.trimmingCharacters(in: .whitespaces).lowercased(), !raw.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch raw {
|
||||||
|
case "success", "ok", "up", "green", "passing":
|
||||||
|
self = .success
|
||||||
|
case "warning", "warn", "yellow", "degraded":
|
||||||
|
self = .warning
|
||||||
|
case "danger", "down", "error", "failed", "failure", "red", "critical":
|
||||||
|
self = .danger
|
||||||
|
case "info", "active", "blue":
|
||||||
|
self = .info
|
||||||
|
case "pending", "queued", "waiting", "scheduled":
|
||||||
|
self = .pending
|
||||||
|
case "done", "complete", "completed", "finished":
|
||||||
|
self = .done
|
||||||
|
case "neutral", "muted", "gray":
|
||||||
|
self = .neutral
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,6 +164,16 @@ public final class ProjectsViewModel {
|
|||||||
projects.map(\.dashboardPath)
|
projects.map(\.dashboardPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-project `.scarf/` directories — watched alongside `dashboardPaths`
|
||||||
|
/// so that file-reading widgets (markdown_file, log_tail, image) refresh
|
||||||
|
/// when their underlying files are added / removed / renamed inside the
|
||||||
|
/// directory by a cron job. In-place file appends within an existing
|
||||||
|
/// file are NOT detected here; the cron job should write atomically
|
||||||
|
/// (write-then-rename) or `touch` dashboard.json after each run.
|
||||||
|
public var projectScarfDirs: [String] {
|
||||||
|
projects.map(\.scarfDir)
|
||||||
|
}
|
||||||
|
|
||||||
private func loadDashboard(for project: ProjectEntry) {
|
private func loadDashboard(for project: ProjectEntry) {
|
||||||
dashboardError = nil
|
dashboardError = nil
|
||||||
if !service.dashboardExists(for: project) {
|
if !service.dashboardExists(for: project) {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ScarfCore
|
||||||
|
|
||||||
|
/// Verifies the lenient `ListItemStatus(raw:)` parser. Real dashboards on
|
||||||
|
/// disk use a mix of canonical names + synonyms (`done`, `info`, `ok`,
|
||||||
|
/// `pending`, `up` are seen on the dev's machine today) — the parser must
|
||||||
|
/// fold those onto the canonical case set without throwing or returning nil
|
||||||
|
/// for the common synonyms. Unknown strings → nil so the renderer can fall
|
||||||
|
/// back to plain text without losing the original.
|
||||||
|
@Suite struct ListItemStatusTests {
|
||||||
|
@Test func canonicalNamesParse() {
|
||||||
|
for c in ListItemStatus.allCases {
|
||||||
|
#expect(ListItemStatus(raw: c.rawValue) == c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func synonymsCollapseToCanonical() {
|
||||||
|
#expect(ListItemStatus(raw: "ok") == .success)
|
||||||
|
#expect(ListItemStatus(raw: "OK") == .success) // case-insensitive
|
||||||
|
#expect(ListItemStatus(raw: " up ") == .success) // whitespace trim
|
||||||
|
#expect(ListItemStatus(raw: "down") == .danger)
|
||||||
|
#expect(ListItemStatus(raw: "error") == .danger)
|
||||||
|
#expect(ListItemStatus(raw: "failed") == .danger)
|
||||||
|
#expect(ListItemStatus(raw: "warn") == .warning)
|
||||||
|
#expect(ListItemStatus(raw: "degraded") == .warning)
|
||||||
|
#expect(ListItemStatus(raw: "active") == .info)
|
||||||
|
#expect(ListItemStatus(raw: "queued") == .pending)
|
||||||
|
#expect(ListItemStatus(raw: "complete") == .done)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unknownReturnsNilNotThrows() {
|
||||||
|
#expect(ListItemStatus(raw: "hologram") == nil)
|
||||||
|
#expect(ListItemStatus(raw: "") == nil)
|
||||||
|
#expect(ListItemStatus(raw: nil) == nil)
|
||||||
|
#expect(ListItemStatus(raw: " ") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func listItemStillDecodesUnknownStatusString() throws {
|
||||||
|
// Backwards-compat invariant: `ListItem.status` stays a free String? on
|
||||||
|
// the wire. Decoding a v2.6 dashboard with a non-canonical status must
|
||||||
|
// succeed and preserve the original string (renderer falls back).
|
||||||
|
let json = #"{"text":"foo","status":"weird"}"#.data(using: .utf8)!
|
||||||
|
let item = try JSONDecoder().decode(ListItem.self, from: json)
|
||||||
|
#expect(item.status == "weird")
|
||||||
|
#expect(ListItemStatus(raw: item.status) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,17 +102,31 @@ struct WidgetView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var unsupportedView: some View {
|
private var unsupportedView: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Label(widget.title, systemImage: "questionmark.app.dashed")
|
HStack(spacing: 6) {
|
||||||
.font(.caption)
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
.font(.caption)
|
||||||
Text("Widget type \"\(widget.type)\" isn't supported in this version of Scarf yet.")
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
Text(widget.title.isEmpty ? "Widget error" : widget.title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||||
|
}
|
||||||
|
Text("Unknown widget type: \"\(widget.type)\"")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Text("This Scarf build doesn't render this widget type. Update Scarf or change the widget type in dashboard.json.")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(.quaternary.opacity(0.5))
|
.background(ScarfColor.warning.opacity(0.08))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.strokeBorder(ScarfColor.warning.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,7 @@ struct ListWidgetView: View {
|
|||||||
}
|
}
|
||||||
if let items = widget.items {
|
if let items = widget.items {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
HStack(spacing: 6) {
|
ListItemRow(item: item)
|
||||||
Image(systemName: statusIcon(item.status))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(statusColor(item.status))
|
|
||||||
Text(item.text)
|
|
||||||
.font(.callout)
|
|
||||||
.strikethrough(item.status == "done")
|
|
||||||
.foregroundStyle(item.status == "done" ? .secondary : .primary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,21 +28,52 @@ struct ListWidgetView: View {
|
|||||||
.background(.quaternary.opacity(0.5))
|
.background(.quaternary.opacity(0.5))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func statusIcon(_ status: String?) -> String {
|
private struct ListItemRow: View {
|
||||||
switch status {
|
let item: ListItem
|
||||||
case "done": return "checkmark.circle.fill"
|
|
||||||
case "active": return "circle.inset.filled"
|
private var typedStatus: ListItemStatus? { ListItemStatus(raw: item.status) }
|
||||||
case "pending": return "circle"
|
|
||||||
default: return "circle"
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
Text(item.text)
|
||||||
|
.font(.callout)
|
||||||
|
.strikethrough(typedStatus == .done)
|
||||||
|
.foregroundStyle(typedStatus == .done ? .secondary : .primary)
|
||||||
|
if typedStatus == nil, let raw = item.status, !raw.isEmpty {
|
||||||
|
Text(raw)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func statusColor(_ status: String?) -> Color {
|
private var iconName: String {
|
||||||
switch status {
|
switch typedStatus {
|
||||||
case "done": return .green
|
case .success, .done: return "checkmark.circle.fill"
|
||||||
case "active": return .blue
|
case .warning: return "exclamationmark.triangle.fill"
|
||||||
default: return .secondary
|
case .danger: return "xmark.octagon.fill"
|
||||||
|
case .info: return "info.circle.fill"
|
||||||
|
case .pending: return "circle.dashed"
|
||||||
|
case .neutral, nil: return "circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tint: Color {
|
||||||
|
switch typedStatus {
|
||||||
|
case .success, .done: return ScarfColor.success
|
||||||
|
case .warning: return ScarfColor.warning
|
||||||
|
case .danger: return ScarfColor.danger
|
||||||
|
case .info: return ScarfColor.info
|
||||||
|
case .pending, .neutral, nil: return .secondary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ final class HermesFileWatcher {
|
|||||||
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
|
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
|
||||||
/// on `stopWatching()`.
|
/// on `stopWatching()`.
|
||||||
private var remotePollTask: Task<Void, Never>?
|
private var remotePollTask: Task<Void, Never>?
|
||||||
|
/// Project directory paths fed to the SSH poller alongside `watchedCorePaths`.
|
||||||
|
/// Updated by `updateProjectWatches` so the remote stream restarts whenever
|
||||||
|
/// the project list changes.
|
||||||
|
private var remoteProjectPaths: [String] = []
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
private let transport: any ServerTransport
|
private let transport: any ServerTransport
|
||||||
@@ -52,17 +56,7 @@ final class HermesFileWatcher {
|
|||||||
|
|
||||||
func startWatching() {
|
func startWatching() {
|
||||||
if context.isRemote {
|
if context.isRemote {
|
||||||
// FSEvents doesn't reach across SSH. Drive lastChangeDate off
|
startRemotePoller()
|
||||||
// the transport's AsyncStream, which polls stat mtime on a
|
|
||||||
// shared ControlMaster channel (~5ms per tick).
|
|
||||||
let stream = transport.watchPaths(watchedCorePaths)
|
|
||||||
remotePollTask = Task { [weak self] in
|
|
||||||
for await _ in stream {
|
|
||||||
await MainActor.run { [weak self] in
|
|
||||||
self?.lastChangeDate = Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +73,21 @@ final class HermesFileWatcher {
|
|||||||
// touches `gateway_state.json` which the watcher catches.
|
// touches `gateway_state.json` which the watcher catches.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// (Re)start the SSH polling stream over the union of `watchedCorePaths`
|
||||||
|
/// and the current `remoteProjectPaths`. Called on initial start and
|
||||||
|
/// whenever `updateProjectWatches` changes the project set.
|
||||||
|
private func startRemotePoller() {
|
||||||
|
remotePollTask?.cancel()
|
||||||
|
let stream = transport.watchPaths(watchedCorePaths + remoteProjectPaths)
|
||||||
|
remotePollTask = Task { [weak self] in
|
||||||
|
for await _ in stream {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.lastChangeDate = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func stopWatching() {
|
func stopWatching() {
|
||||||
for source in coreSources + projectSources {
|
for source in coreSources + projectSources {
|
||||||
source.cancel()
|
source.cancel()
|
||||||
@@ -91,11 +100,26 @@ final class HermesFileWatcher {
|
|||||||
remotePollTask = nil
|
remotePollTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateProjectWatches(_ dashboardPaths: [String]) {
|
/// Watch each project's `dashboard.json` AND its enclosing `.scarf/`
|
||||||
// Remote contexts don't support per-project FSEvents watches today —
|
/// directory. Watching both is what lets file-reading widgets
|
||||||
// the shared mtime poll covers the core set. Adding per-project
|
/// (markdown_file, log_tail, image) refresh when a cron job rewrites
|
||||||
// polling is a Phase 4 polish item.
|
/// a sidecar file: dir-level FSEvents fire on add/remove/rename inside
|
||||||
guard !context.isRemote else { return }
|
/// `.scarf/`, file-level FSEvents fire on dashboard.json content
|
||||||
|
/// changes. In-place writes to an existing sidecar file (e.g., `>>` log
|
||||||
|
/// append) are NOT detected — by convention the cron job should write
|
||||||
|
/// atomically (write-then-rename) or `touch dashboard.json` after each
|
||||||
|
/// run.
|
||||||
|
func updateProjectWatches(dashboardPaths: [String], scarfDirs: [String]) {
|
||||||
|
if context.isRemote {
|
||||||
|
// Restart the SSH poller with the union of core + project dir
|
||||||
|
// paths. `stat -c %Y` on a directory tracks mtime, which ticks
|
||||||
|
// on add/remove/rename inside the dir — same coverage as the
|
||||||
|
// local FSEvents directory watch below.
|
||||||
|
let union = Array(Set(dashboardPaths + scarfDirs))
|
||||||
|
remoteProjectPaths = union.sorted()
|
||||||
|
startRemotePoller()
|
||||||
|
return
|
||||||
|
}
|
||||||
for source in projectSources {
|
for source in projectSources {
|
||||||
source.cancel()
|
source.cancel()
|
||||||
}
|
}
|
||||||
@@ -105,6 +129,11 @@ final class HermesFileWatcher {
|
|||||||
projectSources.append(source)
|
projectSources.append(source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for dir in scarfDirs {
|
||||||
|
if let source = makeSource(for: dir) {
|
||||||
|
projectSources.append(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
|
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ struct ProjectsView: View {
|
|||||||
let project = viewModel.projects.first(where: { $0.name == name }) {
|
let project = viewModel.projects.first(where: { $0.name == name }) {
|
||||||
viewModel.selectProject(project)
|
viewModel.selectProject(project)
|
||||||
}
|
}
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
|
||||||
// Cold-launch deep link or Finder double-click: the router may
|
// Cold-launch deep link or Finder double-click: the router may
|
||||||
// have a URL staged before this view installed the onChange
|
// have a URL staged before this view installed the onChange
|
||||||
// observer below. Without this first-appearance check,
|
// observer below. Without this first-appearance check,
|
||||||
@@ -107,7 +107,7 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
viewModel.load()
|
viewModel.load()
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
|
||||||
}
|
}
|
||||||
.onChange(of: TemplateURLRouter.shared.pendingInstallURL) { _, new in
|
.onChange(of: TemplateURLRouter.shared.pendingInstallURL) { _, new in
|
||||||
// A URL landed *while the app was already running*.
|
// A URL landed *while the app was already running*.
|
||||||
@@ -122,7 +122,7 @@ struct ProjectsView: View {
|
|||||||
if let project = viewModel.projects.first(where: { $0.name == entry.name }) {
|
if let project = viewModel.projects.first(where: { $0.name == entry.name }) {
|
||||||
viewModel.selectProject(project)
|
viewModel.selectProject(project)
|
||||||
}
|
}
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $exportSheetProject) { project in
|
.sheet(item: $exportSheetProject) { project in
|
||||||
@@ -155,7 +155,7 @@ struct ProjectsView: View {
|
|||||||
coordinator.selectedProjectName = nil
|
coordinator.selectedProjectName = nil
|
||||||
}
|
}
|
||||||
viewModel.load()
|
viewModel.load()
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $configEditorProject) { project in
|
.sheet(item: $configEditorProject) { project in
|
||||||
@@ -339,7 +339,7 @@ struct ProjectsView: View {
|
|||||||
.sheet(isPresented: $showingAddSheet) {
|
.sheet(isPresented: $showingAddSheet) {
|
||||||
AddProjectSheet(context: serverContext) { name, path in
|
AddProjectSheet(context: serverContext) { name, path in
|
||||||
viewModel.addProject(name: name, path: path)
|
viewModel.addProject(name: name, path: path)
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(dashboardPaths: viewModel.dashboardPaths, scarfDirs: viewModel.projectScarfDirs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $renameTarget) { target in
|
.sheet(item: $renameTarget) { target in
|
||||||
@@ -485,6 +485,9 @@ struct ProjectsView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
|
// v2.7: file-reading widgets (markdown_file, log_tail, image-local)
|
||||||
|
// resolve their `path` field against this root via WidgetPathResolver.
|
||||||
|
.environment(\.selectedProjectRoot, viewModel.selectedProject?.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func siteTab(_ widget: DashboardWidget) -> some View {
|
private func siteTab(_ widget: DashboardWidget) -> some View {
|
||||||
@@ -600,19 +603,22 @@ struct WidgetView: View {
|
|||||||
ListWidgetView(widget: widget)
|
ListWidgetView(widget: widget)
|
||||||
case "webview":
|
case "webview":
|
||||||
WebviewWidgetView(widget: widget)
|
WebviewWidgetView(widget: widget)
|
||||||
|
case "cron_status":
|
||||||
|
CronStatusWidgetView(widget: widget)
|
||||||
|
case "log_tail":
|
||||||
|
LogTailWidgetView(widget: widget)
|
||||||
|
case "markdown_file":
|
||||||
|
MarkdownFileWidgetView(widget: widget)
|
||||||
|
case "image":
|
||||||
|
ImageWidgetView(widget: widget)
|
||||||
|
case "status_grid":
|
||||||
|
StatusGridWidgetView(widget: widget)
|
||||||
default:
|
default:
|
||||||
VStack {
|
WidgetErrorCard(
|
||||||
Image(systemName: "questionmark.square.dashed")
|
title: widget.title,
|
||||||
.font(.title2)
|
reason: "Unknown widget type: \"\(widget.type)\"",
|
||||||
.foregroundStyle(.secondary)
|
hint: "This Scarf build doesn't render this widget type. Update Scarf or change the widget type in dashboard.json. Known types are listed in tools/widget-schema.json."
|
||||||
Text("Unknown: \(widget.type)")
|
)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 60)
|
|
||||||
.padding(12)
|
|
||||||
.background(.quaternary.opacity(0.5))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Surfaces last-run / next-run / state for a single Hermes cron job by id,
|
||||||
|
/// plus a short tail of its most recent output. Read-only — Run / Pause /
|
||||||
|
/// Resume controls remain on the main Cron tab to keep this widget non-
|
||||||
|
/// destructive (the dashboard shouldn't be a place where the user
|
||||||
|
/// accidentally fires a job).
|
||||||
|
///
|
||||||
|
/// Refreshes whenever `HermesFileWatcher.lastChangeDate` ticks, which
|
||||||
|
/// covers both `cron/jobs.json` mutations and the project-wide `.scarf/`
|
||||||
|
/// watch installed in v2.7. Reads happen detached — never on the main
|
||||||
|
/// actor.
|
||||||
|
struct CronStatusWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
@State private var job: HermesCronJob?
|
||||||
|
@State private var outputTail: String?
|
||||||
|
@State private var loadError: String?
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
private var jobId: String? { widget.jobId }
|
||||||
|
private var lineCount: Int { max(1, min(40, widget.lines ?? 5)) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let jobId, !jobId.isEmpty {
|
||||||
|
content(jobId: jobId)
|
||||||
|
} else {
|
||||||
|
WidgetErrorCard(
|
||||||
|
title: widget.title,
|
||||||
|
reason: "Missing required `jobId` field.",
|
||||||
|
hint: "Set `jobId` to a Hermes cron job's id (visible in the Cron tab)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: fileWatcher.lastChangeDate) {
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(jobId: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
Text(widget.title)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if isLoading && job == nil {
|
||||||
|
ProgressView().controlSize(.mini)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let job {
|
||||||
|
jobRow(job)
|
||||||
|
if let tail = outputTail, !tail.isEmpty {
|
||||||
|
tailView(tail)
|
||||||
|
}
|
||||||
|
} else if let loadError {
|
||||||
|
Text(loadError)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if !isLoading {
|
||||||
|
Text("No cron job with id `\(jobId)`.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(ScarfColor.backgroundSecondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func jobRow(_ job: HermesCronJob) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(job.name)
|
||||||
|
.font(.callout.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
stateBadge(for: job)
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let last = job.lastRunAt, !last.isEmpty {
|
||||||
|
Label(last, systemImage: "checkmark.circle")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
}
|
||||||
|
if let next = job.nextRunAt, !next.isEmpty {
|
||||||
|
Label(next, systemImage: "calendar")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let lastError = job.lastError, !lastError.isEmpty {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.danger)
|
||||||
|
Text(lastError)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(ScarfColor.danger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stateBadge(for job: HermesCronJob) -> some View {
|
||||||
|
let (label, status): (String, ListItemStatus) = {
|
||||||
|
if !job.enabled { return ("DISABLED", .neutral) }
|
||||||
|
switch job.state.lowercased() {
|
||||||
|
case "active", "running": return (job.state.uppercased(), .info)
|
||||||
|
case "paused": return ("PAUSED", .warning)
|
||||||
|
case "error", "failed": return (job.state.uppercased(), .danger)
|
||||||
|
default: return (job.state.uppercased(), .success)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return Text(label)
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(status.tint.opacity(0.15))
|
||||||
|
.foregroundStyle(status.tint)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tailView(_ tail: String) -> some View {
|
||||||
|
let lines = tail.split(separator: "\n", omittingEmptySubsequences: false).suffix(lineCount)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||||
|
Text(String(line))
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.4))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload() async {
|
||||||
|
guard let jobId, !jobId.isEmpty else { return }
|
||||||
|
let context = serverContext
|
||||||
|
let lines = lineCount
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let result: (HermesCronJob?, String?, String?) = await Task.detached {
|
||||||
|
let fs = HermesFileService(context: context)
|
||||||
|
let jobs = fs.loadCronJobs()
|
||||||
|
guard let match = jobs.first(where: { $0.id == jobId }) else {
|
||||||
|
return (nil, nil, "No cron job with id `\(jobId)`.")
|
||||||
|
}
|
||||||
|
let outputRaw = fs.loadCronOutput(jobId: jobId)
|
||||||
|
let trimmed: String? = {
|
||||||
|
guard let outputRaw else { return nil }
|
||||||
|
let stripped = AnsiStripper.strip(outputRaw)
|
||||||
|
let allLines = stripped.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
let kept = allLines.suffix(lines)
|
||||||
|
return kept.joined(separator: "\n")
|
||||||
|
}()
|
||||||
|
return (match, trimmed, nil)
|
||||||
|
}.value
|
||||||
|
self.job = result.0
|
||||||
|
self.outputTail = result.1
|
||||||
|
self.loadError = result.2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Renders a local file (`path`, resolved relative to project root) or a
|
||||||
|
/// remote `url`. `path` wins when both are set. Local files refresh via the
|
||||||
|
/// project-wide `.scarf/` directory watch (v2.7); remote URLs are loaded
|
||||||
|
/// once per appearance and cached by the SwiftUI `AsyncImage` machinery.
|
||||||
|
struct ImageWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@Environment(\.selectedProjectRoot) private var projectRoot
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
@State private var localImage: NSImage?
|
||||||
|
@State private var loadError: String?
|
||||||
|
|
||||||
|
private var displayHeight: CGFloat? {
|
||||||
|
widget.height.map { CGFloat($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
Text(widget.title)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(ScarfColor.backgroundSecondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
if let _ = widget.path {
|
||||||
|
localContent
|
||||||
|
} else if let url = widget.url, let parsed = URL(string: url) {
|
||||||
|
remoteContent(url: parsed)
|
||||||
|
} else {
|
||||||
|
WidgetErrorCard(
|
||||||
|
title: "",
|
||||||
|
reason: "Image widget needs either `path` (local file relative to project root) or `url` (remote)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var localContent: some View {
|
||||||
|
switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) {
|
||||||
|
case .failure(let err):
|
||||||
|
WidgetErrorCard(title: "", reason: err.userMessage)
|
||||||
|
case .success(let resolved):
|
||||||
|
Group {
|
||||||
|
if let img = localImage {
|
||||||
|
Image(nsImage: img)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: displayHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm))
|
||||||
|
} else if let loadError {
|
||||||
|
Text(loadError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: "\(resolved)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)") {
|
||||||
|
await loadLocal(absPath: resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remoteContent(url: URL) -> some View {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .empty:
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
case .success(let img):
|
||||||
|
img.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: displayHeight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm))
|
||||||
|
case .failure(let err):
|
||||||
|
Text("Could not load image: \(err.localizedDescription)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadLocal(absPath: String) async {
|
||||||
|
let context = serverContext
|
||||||
|
let outcome: WidgetIOResult<NSImage> = await Task.detached {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
do {
|
||||||
|
let data = try transport.readFile(absPath)
|
||||||
|
if let img = NSImage(data: data) { return .success(img) }
|
||||||
|
return .failure("File is not a recognized image format.")
|
||||||
|
} catch {
|
||||||
|
return .failure("Could not read file: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
switch outcome {
|
||||||
|
case .success(let img):
|
||||||
|
self.localImage = img
|
||||||
|
self.loadError = nil
|
||||||
|
case .failure(let err):
|
||||||
|
self.localImage = nil
|
||||||
|
self.loadError = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,15 +19,7 @@ struct ListWidgetView: View {
|
|||||||
}
|
}
|
||||||
if let items = widget.items {
|
if let items = widget.items {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
HStack(spacing: 6) {
|
ListItemRow(item: item)
|
||||||
Image(systemName: statusIcon(item.status))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(statusColor(item.status))
|
|
||||||
Text(item.text)
|
|
||||||
.font(.callout)
|
|
||||||
.strikethrough(item.status == "done")
|
|
||||||
.foregroundStyle(item.status == "done" ? .secondary : .primary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,21 +28,59 @@ struct ListWidgetView: View {
|
|||||||
.background(ScarfColor.backgroundSecondary)
|
.background(ScarfColor.backgroundSecondary)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func statusIcon(_ status: String?) -> String {
|
/// One row of a list widget. Maps `item.status` through `ListItemStatus(raw:)`
|
||||||
switch status {
|
/// to a typed badge (icon + color). Unknown strings render as plain text with
|
||||||
case "done": return "checkmark.circle.fill"
|
/// the original string preserved as a trailing badge so nothing's hidden.
|
||||||
case "active": return "circle.inset.filled"
|
struct ListItemRow: View {
|
||||||
case "pending": return "circle"
|
let item: ListItem
|
||||||
default: return "circle"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statusColor(_ status: String?) -> Color {
|
private var typedStatus: ListItemStatus? { ListItemStatus(raw: item.status) }
|
||||||
switch status {
|
|
||||||
case "done": return .green
|
var body: some View {
|
||||||
case "active": return .blue
|
HStack(spacing: 6) {
|
||||||
default: return .secondary
|
Image(systemName: typedStatus?.iconName ?? "circle")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(typedStatus?.tint ?? .secondary)
|
||||||
|
Text(item.text)
|
||||||
|
.font(.callout)
|
||||||
|
.strikethrough(typedStatus == .done)
|
||||||
|
.foregroundStyle(typedStatus == .done ? .secondary : .primary)
|
||||||
|
if typedStatus == nil, let raw = item.status, !raw.isEmpty {
|
||||||
|
Text(raw)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ListItemStatus {
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .success: return "checkmark.circle.fill"
|
||||||
|
case .warning: return "exclamationmark.triangle.fill"
|
||||||
|
case .danger: return "xmark.octagon.fill"
|
||||||
|
case .info: return "info.circle.fill"
|
||||||
|
case .pending: return "circle.dashed"
|
||||||
|
case .done: return "checkmark.circle.fill"
|
||||||
|
case .neutral: return "circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .success, .done: return ScarfColor.success
|
||||||
|
case .warning: return ScarfColor.warning
|
||||||
|
case .danger: return ScarfColor.danger
|
||||||
|
case .info: return ScarfColor.info
|
||||||
|
case .pending: return .secondary
|
||||||
|
case .neutral: return .secondary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Tails the last N lines of a file under the project root, monospaced.
|
||||||
|
/// Best paired with cron jobs that write atomically (write-temp + rename)
|
||||||
|
/// — the project-wide `.scarf/` directory watch (v2.7) refreshes the
|
||||||
|
/// widget when a new file lands. In-place appends to an existing file
|
||||||
|
/// won't tick `lastChangeDate`; the cron job should `touch dashboard.json`
|
||||||
|
/// after each run if it appends in place.
|
||||||
|
struct LogTailWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@Environment(\.selectedProjectRoot) private var projectRoot
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
@State private var loadedTail: String?
|
||||||
|
@State private var loadError: WidgetPathResolver.ResolveError?
|
||||||
|
@State private var ioError: String?
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
private var lineCount: Int { max(1, min(200, widget.lines ?? 20)) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) {
|
||||||
|
case .failure(let err):
|
||||||
|
WidgetErrorCard(
|
||||||
|
title: widget.title,
|
||||||
|
reason: err.userMessage,
|
||||||
|
hint: "Set `path` to a file relative to the project root, e.g. `reports/uptime.log`."
|
||||||
|
)
|
||||||
|
case .success(let resolved):
|
||||||
|
content(for: resolved)
|
||||||
|
.task(id: refreshKey(resolved)) {
|
||||||
|
await reload(absPath: resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshKey(_ resolved: String) -> String {
|
||||||
|
// Force a reload whenever either the widget config or any project
|
||||||
|
// file changes (the latter via fileWatcher.lastChangeDate).
|
||||||
|
"\(resolved)|\(lineCount)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(for absPath: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "doc.text.below.ecg")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
Text(widget.title)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("last \(lineCount)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().controlSize(.mini)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let ioError {
|
||||||
|
Text(ioError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let loadedTail {
|
||||||
|
tailBody(loadedTail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(ScarfColor.backgroundSecondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tailBody(_ tail: String) -> some View {
|
||||||
|
let lines = tail.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
if lines.isEmpty {
|
||||||
|
Text("(empty)").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||||
|
Text(String(line))
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.4))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.sm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload(absPath: String) async {
|
||||||
|
let context = serverContext
|
||||||
|
let n = lineCount
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let outcome: WidgetIOResult<String> = await Task.detached {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
do {
|
||||||
|
let data = try transport.readFile(absPath)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
|
return .failure("File is not UTF-8 — log_tail expects text.")
|
||||||
|
}
|
||||||
|
let stripped = AnsiStripper.strip(text)
|
||||||
|
let parts = stripped.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
return .success(parts.suffix(n).joined(separator: "\n"))
|
||||||
|
} catch {
|
||||||
|
return .failure("Could not read file: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
switch outcome {
|
||||||
|
case .success(let s):
|
||||||
|
self.loadedTail = s
|
||||||
|
self.ioError = nil
|
||||||
|
case .failure(let err):
|
||||||
|
self.loadedTail = nil
|
||||||
|
self.ioError = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Renders a markdown file from the project root through the same
|
||||||
|
/// `MarkdownContentView` pipeline used by the inline `text` widget. Picks
|
||||||
|
/// up edits automatically via the project-wide `.scarf/` directory watch
|
||||||
|
/// (v2.7).
|
||||||
|
struct MarkdownFileWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
@Environment(\.serverContext) private var serverContext
|
||||||
|
@Environment(\.selectedProjectRoot) private var projectRoot
|
||||||
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
|
||||||
|
@State private var loadedContent: String?
|
||||||
|
@State private var ioError: String?
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch WidgetPathResolver.resolve(widget.path, projectRoot: projectRoot) {
|
||||||
|
case .failure(let err):
|
||||||
|
WidgetErrorCard(
|
||||||
|
title: widget.title,
|
||||||
|
reason: err.userMessage,
|
||||||
|
hint: "Set `path` to a markdown file relative to the project root, e.g. `reports/weekly.md`."
|
||||||
|
)
|
||||||
|
case .success(let resolved):
|
||||||
|
content(for: resolved)
|
||||||
|
.task(id: "\(resolved)|\(fileWatcher.lastChangeDate.timeIntervalSince1970)") {
|
||||||
|
await reload(absPath: resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(for absPath: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "doc.text")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
Text(widget.title)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
if isLoading {
|
||||||
|
ProgressView().controlSize(.mini)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let ioError {
|
||||||
|
Text(ioError)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let loadedContent {
|
||||||
|
MarkdownContentView(content: loadedContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(ScarfColor.backgroundSecondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reload(absPath: String) async {
|
||||||
|
let context = serverContext
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let outcome: WidgetIOResult<String> = await Task.detached {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
do {
|
||||||
|
let data = try transport.readFile(absPath)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
|
return .failure("File is not UTF-8 — markdown_file expects text.")
|
||||||
|
}
|
||||||
|
return .success(text)
|
||||||
|
} catch {
|
||||||
|
return .failure("Could not read file: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
switch outcome {
|
||||||
|
case .success(let s):
|
||||||
|
self.loadedContent = s
|
||||||
|
self.ioError = nil
|
||||||
|
case .failure(let err):
|
||||||
|
self.loadedContent = nil
|
||||||
|
self.ioError = err.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,33 @@ import SwiftUI
|
|||||||
import ScarfCore
|
import ScarfCore
|
||||||
import ScarfDesign
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Tiny inline trend line drawn under a `stat` widget's value. Pure SwiftUI
|
||||||
|
/// `Path`, no Swift Charts dependency — stays light enough to render
|
||||||
|
/// dozens per dashboard without measurable cost.
|
||||||
|
struct SparklineView: View {
|
||||||
|
let values: [Double]
|
||||||
|
let tint: Color
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let minV = values.min() ?? 0
|
||||||
|
let maxV = values.max() ?? 1
|
||||||
|
let span = max(0.0001, maxV - minV)
|
||||||
|
let stepX = values.count > 1 ? geo.size.width / CGFloat(values.count - 1) : 0
|
||||||
|
Path { path in
|
||||||
|
for (i, v) in values.enumerated() {
|
||||||
|
let x = CGFloat(i) * stepX
|
||||||
|
let normalized = (v - minV) / span
|
||||||
|
let y = geo.size.height - CGFloat(normalized) * geo.size.height
|
||||||
|
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
|
||||||
|
else { path.addLine(to: CGPoint(x: x, y: y)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stroke(tint.opacity(0.85), lineWidth: 1.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct StatWidgetView: View {
|
struct StatWidgetView: View {
|
||||||
let widget: DashboardWidget
|
let widget: DashboardWidget
|
||||||
|
|
||||||
@@ -30,6 +57,11 @@ struct StatWidgetView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(widgetColor)
|
.foregroundStyle(widgetColor)
|
||||||
}
|
}
|
||||||
|
if let sparkline = widget.sparkline, sparkline.count >= 2 {
|
||||||
|
SparklineView(values: sparkline, tint: widgetColor)
|
||||||
|
.frame(height: 18)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Compact NxM grid of colored cells, one per service / item. Denser than
|
||||||
|
/// `list` for monitoring dashboards with 12+ entities. Uses the same
|
||||||
|
/// `ListItemStatus` vocabulary as the list widget so colors stay consistent.
|
||||||
|
struct StatusGridWidgetView: View {
|
||||||
|
let widget: DashboardWidget
|
||||||
|
|
||||||
|
private var cells: [StatusGridCell] { widget.cells ?? [] }
|
||||||
|
|
||||||
|
/// Auto-fit columns when not specified: aim for ~6 cells per row, capped
|
||||||
|
/// at 12, floored at 4. Ensures both 8-cell and 36-cell grids look ok.
|
||||||
|
private var columnCount: Int {
|
||||||
|
if let n = widget.gridColumns, n > 0 { return min(20, n) }
|
||||||
|
let count = cells.count
|
||||||
|
if count <= 4 { return max(1, count) }
|
||||||
|
if count <= 12 { return 6 }
|
||||||
|
if count <= 24 { return 8 }
|
||||||
|
return 12
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "square.grid.3x3.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
Text(widget.title)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(cells.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
if cells.isEmpty {
|
||||||
|
Text("No cells.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
LazyVGrid(
|
||||||
|
columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: columnCount),
|
||||||
|
spacing: 4
|
||||||
|
) {
|
||||||
|
ForEach(cells) { cell in
|
||||||
|
StatusGridCellView(cell: cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(ScarfColor.backgroundSecondary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StatusGridCellView: View {
|
||||||
|
let cell: StatusGridCell
|
||||||
|
|
||||||
|
private var typedStatus: ListItemStatus { ListItemStatus(raw: cell.status) ?? .neutral }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(typedStatus.tint.opacity(0.85))
|
||||||
|
.frame(height: 18)
|
||||||
|
Text(cell.label)
|
||||||
|
.font(.system(size: 9, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.help(cell.tooltip ?? cell.label + (cell.status.map { " — \($0)" } ?? ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
|
/// Replacement for the legacy "Unknown widget" placeholder. Surfaces the
|
||||||
|
/// widget's own title plus a structured reason so dashboard authors can see
|
||||||
|
/// at a glance what's wrong (unknown type, missing file, parse error, …).
|
||||||
|
///
|
||||||
|
/// Used by the `WidgetView` dispatcher's default branch and (in v2.7+) by
|
||||||
|
/// file-reading widgets that can't load their underlying data.
|
||||||
|
struct WidgetErrorCard: View {
|
||||||
|
let title: String
|
||||||
|
let reason: String
|
||||||
|
var hint: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(ScarfColor.warning)
|
||||||
|
.font(.caption)
|
||||||
|
Text(title.isEmpty ? "Widget error" : title)
|
||||||
|
.scarfStyle(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(reason)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let hint, !hint.isEmpty {
|
||||||
|
Text(hint)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(ScarfColor.warning.opacity(0.08))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: ScarfRadius.lg)
|
||||||
|
.strokeBorder(ScarfColor.warning.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,44 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Strips CSI ANSI escape sequences (`ESC [ ... letter`) so log output
|
||||||
|
/// pasted into the dashboard renders cleanly. Single regex, fast enough
|
||||||
|
/// for the small windows the log_tail / cron_status widgets work with.
|
||||||
|
/// Lightweight result type for file-reading widgets — failure is just a
|
||||||
|
/// human-readable string the widget surfaces in its error card. `Result<_, String>`
|
||||||
|
/// won't compile because `String` doesn't conform to `Error`; this alias
|
||||||
|
/// uses a typed wrapper so the rest of the call sites stay readable.
|
||||||
|
typealias WidgetIOResult<T> = Result<T, WidgetIOError>
|
||||||
|
|
||||||
|
struct WidgetIOError: Error, Sendable {
|
||||||
|
let message: String
|
||||||
|
nonisolated init(_ m: String) { self.message = m }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Result where Failure == WidgetIOError {
|
||||||
|
/// Convenience constructor — `.failure("…")` instead of
|
||||||
|
/// `.failure(WidgetIOError("…"))`. Marked nonisolated so detached
|
||||||
|
/// tasks can call it from outside the main actor.
|
||||||
|
nonisolated static func failure(_ message: String) -> Self {
|
||||||
|
.failure(WidgetIOError(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnsiStripper {
|
||||||
|
/// Single-call regex strip. Compiles per call — log windows are small,
|
||||||
|
/// the cost is negligible, and skipping a `static let` cache means
|
||||||
|
/// callers from `Task.detached` don't fight the Swift 6 actor checker.
|
||||||
|
nonisolated static func strip(_ s: String) -> String {
|
||||||
|
// ESC = \u{1B}; CSI = ESC [ ; final byte is in 0x40..0x7E.
|
||||||
|
guard let pattern = try? NSRegularExpression(
|
||||||
|
pattern: "\u{1B}\\[[0-?]*[ -/]*[@-~]", options: []
|
||||||
|
) else { return s }
|
||||||
|
let range = NSRange(s.startIndex..., in: s)
|
||||||
|
return pattern.stringByReplacingMatches(
|
||||||
|
in: s, options: [], range: range, withTemplate: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func parseColor(_ name: String?) -> Color {
|
func parseColor(_ name: String?) -> Color {
|
||||||
switch name?.lowercased() {
|
switch name?.lowercased() {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Project root the dashboard widgets resolve relative `path` fields against.
|
||||||
|
/// Set by `ProjectsView` from the currently-selected project; nil when no
|
||||||
|
/// project is active. v2.7+ file-reading widgets (markdown_file, log_tail,
|
||||||
|
/// image-local) read this via the environment.
|
||||||
|
private struct SelectedProjectRootKey: EnvironmentKey {
|
||||||
|
static let defaultValue: String? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var selectedProjectRoot: String? {
|
||||||
|
get { self[SelectedProjectRootKey.self] }
|
||||||
|
set { self[SelectedProjectRootKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a widget's `path` field against the project root. Rejects paths
|
||||||
|
/// that escape the project boundary via `..` segments after normalization,
|
||||||
|
/// rejects absolute paths, and rejects empty / nil inputs. The returned
|
||||||
|
/// path is suitable to hand to `transport.readFile`.
|
||||||
|
///
|
||||||
|
/// Returns nil + the reason if the path is invalid; widgets surface that
|
||||||
|
/// reason via `WidgetErrorCard`.
|
||||||
|
enum WidgetPathResolver {
|
||||||
|
enum ResolveError: Error, Equatable {
|
||||||
|
case noProject
|
||||||
|
case missingPath
|
||||||
|
case absolutePath
|
||||||
|
case escapesProject
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(_ relativePath: String?, projectRoot: String?) -> Result<String, ResolveError> {
|
||||||
|
guard let projectRoot, !projectRoot.isEmpty else { return .failure(.noProject) }
|
||||||
|
guard let relativePath, !relativePath.isEmpty else { return .failure(.missingPath) }
|
||||||
|
if relativePath.hasPrefix("/") { return .failure(.absolutePath) }
|
||||||
|
// Strip a single leading "./" — common in template-authored paths.
|
||||||
|
let trimmed = relativePath.hasPrefix("./") ? String(relativePath.dropFirst(2)) : relativePath
|
||||||
|
// Walk the segments and reject any "..": the project root is the
|
||||||
|
// trust boundary, anything reaching outside it is rejected. We do
|
||||||
|
// this BEFORE join+standardize so symlink games can't smuggle a
|
||||||
|
// ".." through path canonicalization.
|
||||||
|
let segments = trimmed.split(separator: "/", omittingEmptySubsequences: true)
|
||||||
|
for s in segments where s == ".." { return .failure(.escapesProject) }
|
||||||
|
let joined = (projectRoot as NSString).appendingPathComponent(trimmed)
|
||||||
|
let standardized = (joined as NSString).standardizingPath
|
||||||
|
// Belt and suspenders: ensure the standardized path is still
|
||||||
|
// beneath projectRoot. Standardizing resolves "./" and may follow
|
||||||
|
// symlinks; this catch checks the final string prefix.
|
||||||
|
let rootStd = (projectRoot as NSString).standardizingPath
|
||||||
|
if !standardized.hasPrefix(rootStd) {
|
||||||
|
return .failure(.escapesProject)
|
||||||
|
}
|
||||||
|
return .success(standardized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WidgetPathResolver.ResolveError {
|
||||||
|
var userMessage: String {
|
||||||
|
switch self {
|
||||||
|
case .noProject: return "No project selected."
|
||||||
|
case .missingPath: return "Missing required `path` field."
|
||||||
|
case .absolutePath: return "Path must be relative to the project root, not absolute."
|
||||||
|
case .escapesProject: return "Path escapes the project root (`..` segments are not allowed)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3087,6 +3087,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Auto-refresh OAuth tokens daily" : {
|
||||||
|
"comment" : "A toggle that enables or disables automatic refresh of OAuth tokens.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Aux Models" : {
|
"Aux Models" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -11217,6 +11221,10 @@
|
|||||||
"comment" : "A description of a file that is not part of the template's installation.",
|
"comment" : "A description of a file that is not part of the template's installation.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Keep tokens fresh" : {
|
||||||
|
"comment" : "Title of a section that lets you enable a cron job that refreshes OAuth tokens.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Keep typing to send as a message, or press Esc." : {
|
"Keep typing to send as a message, or press Esc." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -17369,6 +17377,10 @@
|
|||||||
"comment" : "A message that appears when the app is refreshing",
|
"comment" : "A message that appears when the app is refreshing",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Registers a `%@` cron job that runs at 4am daily. Booting a Hermes session is what triggers token refresh — without this, refresh tokens silently expire if you go ~30 days without using Scarf." : {
|
||||||
|
"comment" : "A paragraph describing the purpose of the auto-refresh toggle.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Reload" : {
|
"Reload" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -25378,6 +25390,10 @@
|
|||||||
"comment" : "A label for the user's name.",
|
"comment" : "A label for the user's name.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Your Nous subscription was last refreshed %lld days ago. Enable the toggle above to prevent the refresh token from expiring." : {
|
||||||
|
"comment" : "A warning that the refresh token is stale.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Your tools will now route through your subscription." : {
|
"Your tools will now route through your subscription." : {
|
||||||
"comment" : "A description of the success state of the",
|
"comment" : "A description of the success state of the",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
@@ -411,6 +411,64 @@ h1, h2, h3 { line-height: 1.25; }
|
|||||||
}
|
}
|
||||||
.widget-list-status[data-status="up"] { background: rgba(42,168,118,0.18); color: var(--accent-dark); }
|
.widget-list-status[data-status="up"] { background: rgba(42,168,118,0.18); color: var(--accent-dark); }
|
||||||
.widget-list-status[data-status="down"] { background: rgba(217,83,79,0.18); color: var(--red); }
|
.widget-list-status[data-status="down"] { background: rgba(217,83,79,0.18); color: var(--red); }
|
||||||
|
/* v2.7 typed semantic statuses (mirrors ScarfDesign palette + Swift ListItemStatus enum). */
|
||||||
|
.widget-list-status.status-success { background: rgba(42,168,118,0.18); color: var(--accent-dark); }
|
||||||
|
.widget-list-status.status-warning { background: rgba(240,173,78,0.20); color: var(--orange); }
|
||||||
|
.widget-list-status.status-danger { background: rgba(217,83,79,0.18); color: var(--red); }
|
||||||
|
.widget-list-status.status-info { background: rgba(91,143,225,0.20); color: var(--blue); }
|
||||||
|
.widget-list-status.status-pending { background: rgba(0,0,0,0.06); color: var(--fg-muted); }
|
||||||
|
.widget-list-status.status-done { background: rgba(42,168,118,0.14); color: var(--accent-dark); }
|
||||||
|
.widget-list-status.status-neutral { background: rgba(0,0,0,0.06); color: var(--fg-muted); }
|
||||||
|
.widget-list-status.status-unknown { background: rgba(0,0,0,0.06); color: var(--fg-muted); font-style: italic; }
|
||||||
|
.widget-list-item-done .widget-list-text { text-decoration: line-through; color: var(--fg-muted); }
|
||||||
|
|
||||||
|
/* v2.7 structured widget error card (mirrors Swift WidgetErrorCard). */
|
||||||
|
.widget-error {
|
||||||
|
background: rgba(240,173,78,0.08);
|
||||||
|
border: 1px solid rgba(240,173,78,0.30);
|
||||||
|
}
|
||||||
|
.widget-error-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||||
|
.widget-error-icon { color: var(--orange); font-size: 14px; }
|
||||||
|
.widget-error-reason { font-size: 14px; color: var(--fg); }
|
||||||
|
.widget-error-hint { margin-top: 4px; font-size: 11px; color: var(--fg-muted); }
|
||||||
|
|
||||||
|
/* v2.7 cron_status / log_tail / markdown_file (catalog preview placeholders). */
|
||||||
|
.widget-cron-status, .widget-log-tail, .widget-markdown-file { padding: 12px; }
|
||||||
|
.widget-cron-head { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
||||||
|
.widget-cron-icon { font-size: 14px; color: var(--fg-muted); }
|
||||||
|
.widget-cron-meta { font-size: 13px; color: var(--fg); font-family: monospace; }
|
||||||
|
.widget-cron-hint { margin-top: 6px; font-size: 11px; color: var(--fg-muted); font-style: italic; }
|
||||||
|
|
||||||
|
/* v2.7 image widget. */
|
||||||
|
.widget-image { padding: 12px; }
|
||||||
|
.widget-image-img { display: block; max-width: 100%; height: auto; margin-top: 8px; border-radius: 4px; }
|
||||||
|
|
||||||
|
/* v2.7 status_grid widget — compact NxM grid with semantic-status swatches. */
|
||||||
|
.widget-status-grid { padding: 12px; }
|
||||||
|
.widget-status-grid-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 6), 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.widget-status-grid-cell { display: flex; flex-direction: column; gap: 2px; align-items: center; }
|
||||||
|
.widget-status-grid-swatch { width: 100%; height: 18px; border-radius: 3px; }
|
||||||
|
.widget-status-grid-label { font-size: 9px; color: var(--fg-muted); text-align: center; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.widget-status-grid-swatch.status-success { background: rgba(42,168,118,0.85); }
|
||||||
|
.widget-status-grid-swatch.status-warning { background: rgba(240,173,78,0.85); }
|
||||||
|
.widget-status-grid-swatch.status-danger { background: rgba(217,83,79,0.85); }
|
||||||
|
.widget-status-grid-swatch.status-info { background: rgba(91,143,225,0.85); }
|
||||||
|
.widget-status-grid-swatch.status-pending { background: rgba(120,120,120,0.40); }
|
||||||
|
.widget-status-grid-swatch.status-done { background: rgba(42,168,118,0.55); }
|
||||||
|
.widget-status-grid-swatch.status-neutral { background: rgba(120,120,120,0.30); }
|
||||||
|
|
||||||
|
/* v2.7 sparkline under stat value. Inherits color from widget-stat data-color. */
|
||||||
|
.widget-stat-sparkline { display: block; margin-top: 4px; width: 100%; }
|
||||||
|
.widget-stat[data-color="accent"] .widget-stat-sparkline,
|
||||||
|
.widget-stat[data-color="green"] .widget-stat-sparkline { color: var(--accent-dark); }
|
||||||
|
.widget-stat[data-color="red"] .widget-stat-sparkline { color: var(--red); }
|
||||||
|
.widget-stat[data-color="blue"] .widget-stat-sparkline { color: var(--blue); }
|
||||||
|
.widget-stat[data-color="orange"] .widget-stat-sparkline { color: var(--orange); }
|
||||||
|
|
||||||
/* chart */
|
/* chart */
|
||||||
.widget-chart-svg { width: 100%; height: auto; margin-top: 8px; }
|
.widget-chart-svg { width: 100%; height: auto; margin-top: 8px; }
|
||||||
|
|||||||
+214
-15
@@ -6,16 +6,13 @@
|
|||||||
// shows a live preview of exactly what the user's project dashboard
|
// shows a live preview of exactly what the user's project dashboard
|
||||||
// will look like after install.
|
// will look like after install.
|
||||||
//
|
//
|
||||||
// Widget types mirrored from the Swift dispatcher:
|
// CANONICAL VOCABULARY: tools/widget-schema.json. The catalog validator
|
||||||
// stat — big number + label + icon + color
|
// (tools/build-catalog.py) and the agent-authoring SKILL.md both read
|
||||||
// progress — label + 0..1 bar
|
// from there. When you add a renderer below, mirror the Swift view in
|
||||||
// text — markdown (tiny subset renderer)
|
// scarf/scarf/Features/Projects/Views/Widgets/ AND add an entry to
|
||||||
// table — plain HTML table
|
// widget-schema.json.
|
||||||
// list — bulleted list with optional status badge
|
|
||||||
// chart — SVG line/bar by series
|
|
||||||
// webview — sandboxed <iframe>
|
|
||||||
//
|
//
|
||||||
// Vanilla JS, no build step, no external deps. ~300 lines.
|
// Vanilla JS, no build step, no external deps.
|
||||||
|
|
||||||
(function (global) {
|
(function (global) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -79,6 +76,11 @@
|
|||||||
case "list": return renderList(widget);
|
case "list": return renderList(widget);
|
||||||
case "chart": return renderChart(widget);
|
case "chart": return renderChart(widget);
|
||||||
case "webview": return renderWebview(widget);
|
case "webview": return renderWebview(widget);
|
||||||
|
case "cron_status": return renderCronStatus(widget);
|
||||||
|
case "log_tail": return renderLogTail(widget);
|
||||||
|
case "markdown_file": return renderMarkdownFile(widget);
|
||||||
|
case "image": return renderImage(widget);
|
||||||
|
case "status_grid": return renderStatusGrid(widget);
|
||||||
default: return renderUnknown(widget);
|
default: return renderUnknown(widget);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -103,9 +105,41 @@
|
|||||||
if (widget.subtitle) {
|
if (widget.subtitle) {
|
||||||
card.appendChild(elt("div", "widget-stat-subtitle", widget.subtitle));
|
card.appendChild(elt("div", "widget-stat-subtitle", widget.subtitle));
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(widget.sparkline) && widget.sparkline.length >= 2) {
|
||||||
|
card.appendChild(renderSparkline(widget.sparkline));
|
||||||
|
}
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** v2.7 — inline trend line under a stat value. SVG, no Chart.js. */
|
||||||
|
function renderSparkline(values) {
|
||||||
|
const w = 120;
|
||||||
|
const h = 18;
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const span = Math.max(0.0001, max - min);
|
||||||
|
const stepX = values.length > 1 ? w / (values.length - 1) : 0;
|
||||||
|
let path = "";
|
||||||
|
values.forEach((v, i) => {
|
||||||
|
const x = (i * stepX).toFixed(2);
|
||||||
|
const y = (h - ((v - min) / span) * h).toFixed(2);
|
||||||
|
path += (i === 0 ? "M" : "L") + x + "," + y + " ";
|
||||||
|
});
|
||||||
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||||
|
svg.setAttribute("class", "widget-stat-sparkline");
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
||||||
|
svg.setAttribute("width", String(w));
|
||||||
|
svg.setAttribute("height", String(h));
|
||||||
|
svg.setAttribute("preserveAspectRatio", "none");
|
||||||
|
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||||
|
p.setAttribute("d", path.trim());
|
||||||
|
p.setAttribute("fill", "none");
|
||||||
|
p.setAttribute("stroke", "currentColor");
|
||||||
|
p.setAttribute("stroke-width", "1.2");
|
||||||
|
svg.appendChild(p);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
function displayValue(v) {
|
function displayValue(v) {
|
||||||
if (v === null || v === undefined) return "—";
|
if (v === null || v === undefined) return "—";
|
||||||
if (typeof v === "number") {
|
if (typeof v === "number") {
|
||||||
@@ -263,16 +297,40 @@
|
|||||||
// List
|
// List
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Maps a `ListItem.status` string (free-form on the wire) to a canonical
|
||||||
|
// semantic status. Mirrors the Swift `ListItemStatus(raw:)` lenient parse
|
||||||
|
// — accepts canonical names + common synonyms (`ok`/`up` → success,
|
||||||
|
// `down`/`error` → danger, `active` → info). Returns null for unrecognized
|
||||||
|
// values so the renderer can fall back to a neutral text badge.
|
||||||
|
const STATUS_SYNONYMS = {
|
||||||
|
success: "success", ok: "success", up: "success", green: "success", passing: "success",
|
||||||
|
warning: "warning", warn: "warning", yellow: "warning", degraded: "warning",
|
||||||
|
danger: "danger", down: "danger", error: "danger", failed: "danger", failure: "danger", red: "danger", critical: "danger",
|
||||||
|
info: "info", active: "info", blue: "info",
|
||||||
|
pending: "pending", queued: "pending", waiting: "pending", scheduled: "pending",
|
||||||
|
done: "done", complete: "done", completed: "done", finished: "done",
|
||||||
|
neutral: "neutral", muted: "neutral", gray: "neutral",
|
||||||
|
};
|
||||||
|
function canonicalStatus(raw) {
|
||||||
|
if (typeof raw !== "string") return null;
|
||||||
|
const key = raw.trim().toLowerCase();
|
||||||
|
return STATUS_SYNONYMS[key] || null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderList(widget) {
|
function renderList(widget) {
|
||||||
const card = elt("div", "widget widget-list");
|
const card = elt("div", "widget widget-list");
|
||||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
const ul = elt("ul", "widget-list-items");
|
const ul = elt("ul", "widget-list-items");
|
||||||
for (const item of widget.items || []) {
|
for (const item of widget.items || []) {
|
||||||
const li = elt("li", "widget-list-item");
|
const li = elt("li", "widget-list-item");
|
||||||
|
const canon = canonicalStatus(item.status);
|
||||||
|
if (canon === "done") li.classList.add("widget-list-item-done");
|
||||||
li.appendChild(elt("span", "widget-list-text", item.text || ""));
|
li.appendChild(elt("span", "widget-list-text", item.text || ""));
|
||||||
if (item.status) {
|
if (item.status) {
|
||||||
const badge = elt("span", "widget-list-status", item.status);
|
const cls = canon ? `widget-list-status status-${canon}` : "widget-list-status status-unknown";
|
||||||
badge.dataset.status = item.status;
|
const badge = elt("span", cls, canon || item.status);
|
||||||
|
badge.dataset.status = canon || item.status;
|
||||||
|
if (!canon) badge.title = `unknown status: ${item.status}`;
|
||||||
li.appendChild(badge);
|
li.appendChild(badge);
|
||||||
}
|
}
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
@@ -376,15 +434,156 @@
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// log_tail / markdown_file / image / status_grid — v2.7
|
||||||
|
// The first three are file-reading widgets; the catalog has no project
|
||||||
|
// filesystem to read from, so we render an annotated placeholder. The
|
||||||
|
// template-author skill (SKILL.md Widget Catalog) tells users this is
|
||||||
|
// expected on the catalog and that real data appears in-app.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderLogTail(widget) {
|
||||||
|
const card = elt("div", "widget widget-log-tail");
|
||||||
|
const head = elt("div", "widget-cron-head");
|
||||||
|
head.appendChild(elt("span", "widget-cron-icon", "⌙"));
|
||||||
|
head.appendChild(elt("span", "widget-title", widget.title || ""));
|
||||||
|
card.appendChild(head);
|
||||||
|
if (!widget.path) {
|
||||||
|
card.appendChild(renderWidgetError(
|
||||||
|
"", "Missing required `path` field.",
|
||||||
|
"Set `path` to a file relative to the project root."
|
||||||
|
));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
const lines = Math.max(1, Math.min(200, widget.lines || 20));
|
||||||
|
card.appendChild(elt("div", "widget-cron-meta",
|
||||||
|
`Tails last ${lines} line${lines === 1 ? "" : "s"} of ${widget.path}`));
|
||||||
|
card.appendChild(elt("div", "widget-cron-hint",
|
||||||
|
"Live tail appears in Scarf after install."));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdownFile(widget) {
|
||||||
|
const card = elt("div", "widget widget-markdown-file");
|
||||||
|
const head = elt("div", "widget-cron-head");
|
||||||
|
head.appendChild(elt("span", "widget-cron-icon", "📄"));
|
||||||
|
head.appendChild(elt("span", "widget-title", widget.title || ""));
|
||||||
|
card.appendChild(head);
|
||||||
|
if (!widget.path) {
|
||||||
|
card.appendChild(renderWidgetError(
|
||||||
|
"", "Missing required `path` field.",
|
||||||
|
"Set `path` to a markdown file relative to the project root."
|
||||||
|
));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
card.appendChild(elt("div", "widget-cron-meta",
|
||||||
|
`Renders markdown from: ${widget.path}`));
|
||||||
|
card.appendChild(elt("div", "widget-cron-hint",
|
||||||
|
"File contents appear in Scarf after install."));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImage(widget) {
|
||||||
|
const card = elt("div", "widget widget-image");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
if (widget.url) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "widget-image-img";
|
||||||
|
img.src = widget.url;
|
||||||
|
img.alt = widget.title || "";
|
||||||
|
if (widget.height) img.style.maxHeight = `${widget.height}px`;
|
||||||
|
card.appendChild(img);
|
||||||
|
} else if (widget.path) {
|
||||||
|
card.appendChild(elt("div", "widget-cron-meta",
|
||||||
|
`Local image: ${widget.path}`));
|
||||||
|
card.appendChild(elt("div", "widget-cron-hint",
|
||||||
|
"Local files render in Scarf after install."));
|
||||||
|
} else {
|
||||||
|
card.appendChild(renderWidgetError(
|
||||||
|
"", "Image widget needs either `path` (local) or `url` (remote)."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusGrid(widget) {
|
||||||
|
const card = elt("div", "widget widget-status-grid");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
const cells = Array.isArray(widget.cells) ? widget.cells : [];
|
||||||
|
if (cells.length === 0) {
|
||||||
|
card.appendChild(elt("div", "widget-cron-meta", "No cells."));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
let cols = widget.gridColumns;
|
||||||
|
if (typeof cols !== "number" || cols <= 0) {
|
||||||
|
if (cells.length <= 4) cols = Math.max(1, cells.length);
|
||||||
|
else if (cells.length <= 12) cols = 6;
|
||||||
|
else if (cells.length <= 24) cols = 8;
|
||||||
|
else cols = 12;
|
||||||
|
}
|
||||||
|
const grid = elt("div", "widget-status-grid-grid");
|
||||||
|
grid.style.setProperty("--cols", String(cols));
|
||||||
|
for (const cell of cells) {
|
||||||
|
const square = elt("div", "widget-status-grid-cell");
|
||||||
|
const canon = canonicalStatus(cell.status) || "neutral";
|
||||||
|
const swatch = elt("div", `widget-status-grid-swatch status-${canon}`);
|
||||||
|
square.title = cell.tooltip || (cell.label + (cell.status ? ` — ${cell.status}` : ""));
|
||||||
|
square.appendChild(swatch);
|
||||||
|
square.appendChild(elt("div", "widget-status-grid-label", cell.label || ""));
|
||||||
|
grid.appendChild(square);
|
||||||
|
}
|
||||||
|
card.appendChild(grid);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Cron status (catalog preview — no live cron data)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderCronStatus(widget) {
|
||||||
|
const card = elt("div", "widget widget-cron-status");
|
||||||
|
const head = elt("div", "widget-cron-head");
|
||||||
|
const icon = elt("span", "widget-cron-icon", "↻");
|
||||||
|
head.appendChild(icon);
|
||||||
|
head.appendChild(elt("span", "widget-title", widget.title || ""));
|
||||||
|
card.appendChild(head);
|
||||||
|
if (!widget.jobId) {
|
||||||
|
card.appendChild(elt("div", "widget-cron-meta",
|
||||||
|
"Missing required `jobId` field."));
|
||||||
|
} else {
|
||||||
|
card.appendChild(elt("div", "widget-cron-meta",
|
||||||
|
`Tracks Hermes cron job: ${widget.jobId}`));
|
||||||
|
card.appendChild(elt("div", "widget-cron-hint",
|
||||||
|
"Live status (last run, next run, output tail) appears in Scarf after install."));
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Unknown / placeholder
|
// Unknown / placeholder
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
function renderUnknown(widget) {
|
function renderUnknown(widget) {
|
||||||
const card = elt("div", "widget widget-unknown");
|
return renderWidgetError(
|
||||||
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
widget.title,
|
||||||
card.appendChild(elt("div", "widget-unknown-body",
|
`Unknown widget type: "${widget.type}"`,
|
||||||
`Unknown widget type: ${widget.type}`));
|
"This catalog renderer doesn't know about this widget type. The Scarf app may render it correctly if it's been added in a newer release."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured error card. Mirrors `WidgetErrorCard` on the Swift side and
|
||||||
|
* is also used by file-reading widgets (markdown_file, log_tail, image)
|
||||||
|
* when their underlying data can't be loaded.
|
||||||
|
*/
|
||||||
|
function renderWidgetError(title, reason, hint) {
|
||||||
|
const card = elt("div", "widget widget-unknown widget-error");
|
||||||
|
const head = elt("div", "widget-error-head");
|
||||||
|
head.appendChild(elt("span", "widget-error-icon", "⚠"));
|
||||||
|
head.appendChild(elt("span", "widget-title", title || "Widget error"));
|
||||||
|
card.appendChild(head);
|
||||||
|
card.appendChild(elt("div", "widget-error-reason", reason));
|
||||||
|
if (hint) card.appendChild(elt("div", "widget-error-hint", hint));
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Minimum required files under `staging/`:
|
|||||||
|
|
||||||
- **`AGENTS.md`** — the cross-agent spec. Include: project layout, first-run bootstrap (if any), what each cron job expects to happen, and answers to common user prompts (`"what's the status"`, `"add a X"`, etc.).
|
- **`AGENTS.md`** — the cross-agent spec. Include: project layout, first-run bootstrap (if any), what each cron job expects to happen, and answers to common user prompts (`"what's the status"`, `"add a X"`, etc.).
|
||||||
|
|
||||||
- **`dashboard.json`** — the Scarf dashboard that renders on the catalog detail page and after install. See [awizemann/site-status-checker/staging/dashboard.json](awizemann/site-status-checker/staging/dashboard.json) for the schema in action.
|
- **`dashboard.json`** — the Scarf dashboard that renders on the catalog detail page and after install. See [awizemann/site-status-checker/staging/dashboard.json](awizemann/site-status-checker/staging/dashboard.json) for the schema in action. The canonical widget vocabulary lives at [`tools/widget-schema.json`](../tools/widget-schema.json) — the catalog validator reads it and every widget type must appear there. **v2.7+ adds five new widget types** (`markdown_file`, `log_tail`, `cron_status`, `image`, `status_grid`) plus a `sparkline` field on `stat` and a typed status enum on `list` items (`success` / `warning` / `danger` / `info` / `pending` / `done` / `neutral`; common synonyms like `ok` / `up` / `down` also accepted). File-reading widgets (`markdown_file`, `log_tail`, `image`-with-`path`) take a `path` field relative to the project root — by convention place the underlying files inside `.scarf/` so the project-wide directory watch refreshes them automatically.
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
|
|||||||
@@ -73,15 +73,21 @@ Map to cron expressions:
|
|||||||
|
|
||||||
### 3. What the dashboard shows
|
### 3. What the dashboard shows
|
||||||
|
|
||||||
Explain the seven widget types (see Widget Catalog below) in plain English, then ask which ones feel right. Offer concrete suggestions based on the purpose:
|
Explain the widget catalog (see Widget Catalog sections below) in plain English, then ask which ones feel right. Offer concrete suggestions based on the purpose:
|
||||||
|
|
||||||
- Counting things (open PRs, failing tests, up/down sites) → `stat` widgets.
|
- Counting things (open PRs, failing tests, up/down sites) → `stat` widgets. Add `sparkline: [Number]` (v2.7+) if you have a recent trend handy.
|
||||||
- A list of items with status → `list` with `text` + `status` per item.
|
- A list of items with status → `list` with `text` + `status` per item (≤8 items). 12+ items → use `status_grid` (v2.7+) for a denser layout.
|
||||||
- Time-series data → `chart` with `line` or `bar` type.
|
- Time-series data → `chart` with `line` or `bar` type.
|
||||||
- Rows × columns of heterogeneous data → `table`.
|
- Rows × columns of heterogeneous data → `table`.
|
||||||
- A live URL (useful for monitoring a site) → `webview`. **Including a webview widget exposes a Site tab** next to the Dashboard tab — worth noting to the user.
|
- A live URL (useful for monitoring a site) → `webview`. **Including a webview widget exposes a Site tab** next to the Dashboard tab — worth noting to the user.
|
||||||
|
- A static image / generated chart → `image` (v2.7+; local file or remote URL).
|
||||||
- A progress bar for something with a clear 0-to-N scale → `progress`.
|
- A progress bar for something with a clear 0-to-N scale → `progress`.
|
||||||
- Static help / markdown → `text` with `format: "markdown"`.
|
- Static help / markdown → `text` with `format: "markdown"`.
|
||||||
|
- A longer markdown report the cron job writes → `markdown_file` (v2.7+; reads from a file under the project, refreshes when the cron job rewrites it).
|
||||||
|
- The last N lines of a log/output file → `log_tail` (v2.7+).
|
||||||
|
- The state of one Hermes cron job (last run / next run / output) → `cron_status` (v2.7+).
|
||||||
|
|
||||||
|
**v2.7 file-reading widgets** (`markdown_file`, `log_tail`, `image`-with-`path`) read files relative to the project root. **By convention, write the underlying files inside `<project>/.scarf/`** (e.g. `.scarf/reports/weekly.md`, `.scarf/reports/run.log`) so the project-wide directory watch picks up changes and the widgets refresh automatically. Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes, so cron jobs writing outside `.scarf/` should `touch dashboard.json` after each run.
|
||||||
|
|
||||||
### 4. Configuration needs
|
### 4. Configuration needs
|
||||||
|
|
||||||
@@ -145,12 +151,12 @@ Every row MUST have the same length as `columns`.
|
|||||||
```json
|
```json
|
||||||
{ "type": "list", "title": "Watched Sites",
|
{ "type": "list", "title": "Watched Sites",
|
||||||
"items": [
|
"items": [
|
||||||
{ "text": "https://example.com", "status": "up" },
|
{ "text": "https://example.com", "status": "success" },
|
||||||
{ "text": "https://example.org", "status": "down" }
|
{ "text": "https://example.org", "status": "danger" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
`status` values: `"up"`, `"down"`, `"pending"`, `"ok"`, `"warn"`, `"error"` — render as coloured badges.
|
**Status values (typed in v2.7+):** prefer the canonical set — `"success"`, `"warning"`, `"danger"`, `"info"`, `"pending"`, `"done"`, `"neutral"`. Common synonyms also work and map to the canonical case (`"ok"`, `"up"`, `"passing"` → success; `"down"`, `"error"`, `"failed"` → danger; `"active"` → info; `"complete"`, `"finished"` → done; `"warn"`, `"degraded"` → warning). Unknown strings render as plain text rather than crashing — old dashboards using ad-hoc statuses keep working unchanged. **For new templates, prefer the canonical names** so the colors stay predictable across Scarf releases.
|
||||||
|
|
||||||
### `webview` — embedded live URL
|
### `webview` — embedded live URL
|
||||||
```json
|
```json
|
||||||
@@ -159,6 +165,65 @@ Every row MUST have the same length as `columns`.
|
|||||||
```
|
```
|
||||||
**Important:** including any `webview` widget in a dashboard exposes a **Site** tab next to the Dashboard tab in the project view. Useful for templates that watch something renderable. The agent can update `url` on cron runs to keep the Site tab in sync with config (e.g., set it to `values.sites[0]`).
|
**Important:** including any `webview` widget in a dashboard exposes a **Site** tab next to the Dashboard tab in the project view. Useful for templates that watch something renderable. The agent can update `url` on cron runs to keep the Site tab in sync with config (e.g., set it to `values.sites[0]`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Catalog (v2.7+ — file-reading and richer widgets)
|
||||||
|
|
||||||
|
Five new widget types landed in v2.7. They all read from disk relative to the project root, and refresh automatically when any file under `<project>/.scarf/` changes — so a cron job that writes `<project>/.scarf/reports/uptime.md` will trigger the corresponding widget to re-render. **Convention: place the underlying files inside `.scarf/` (or a subdir of it) so the directory watch picks them up.** Files outside `.scarf/` work too but only refresh when `dashboard.json` itself changes.
|
||||||
|
|
||||||
|
### `markdown_file` — renders a markdown file from disk
|
||||||
|
```json
|
||||||
|
{ "type": "markdown_file", "title": "This Week", "path": ".scarf/reports/weekly.md" }
|
||||||
|
```
|
||||||
|
`path` is relative to the project root. Refuses absolute paths and `..` escape. Use this when the cron job writes a longer-form report; use `text` when the content is short and authored inline.
|
||||||
|
|
||||||
|
### `log_tail` — last N lines of a file, monospaced
|
||||||
|
```json
|
||||||
|
{ "type": "log_tail", "title": "Last cron run", "path": ".scarf/reports/run.log", "lines": 30 }
|
||||||
|
```
|
||||||
|
Default `lines` is 20, capped at 200. ANSI color codes are stripped automatically. Pair with cron jobs that write atomic log snapshots (write-temp + rename) — in-place appends won't refresh until `dashboard.json` is touched.
|
||||||
|
|
||||||
|
### `cron_status` — last/next run + state for one Hermes cron job
|
||||||
|
```json
|
||||||
|
{ "type": "cron_status", "title": "Uptime sweep", "jobId": "uptime-sweep", "lines": 5 }
|
||||||
|
```
|
||||||
|
`jobId` matches a `HermesCronJob.id` (visible in the Cron tab). Read-only — Run/Pause/Resume actions stay on the Cron tab; this widget only reports state. Great for dashboards that drive a single scheduled task.
|
||||||
|
|
||||||
|
### `image` — local file or remote URL
|
||||||
|
```json
|
||||||
|
{ "type": "image", "title": "Latency p95", "path": ".scarf/reports/latency.png", "height": 200 }
|
||||||
|
{ "type": "image", "title": "Build status", "url": "https://example.com/badge.svg" }
|
||||||
|
```
|
||||||
|
Either `path` (local, relative to project root) OR `url` (remote). `path` wins when both are set. Useful for chart PNGs the cron job generates with matplotlib / Plotly.
|
||||||
|
|
||||||
|
### `status_grid` — compact NxM grid of colored cells
|
||||||
|
```json
|
||||||
|
{ "type": "status_grid", "title": "Fleet", "gridColumns": 6, "cells": [
|
||||||
|
{ "label": "us-east-1", "status": "success", "tooltip": "200ms p50" },
|
||||||
|
{ "label": "us-west-2", "status": "warning", "tooltip": "elevated latency" },
|
||||||
|
{ "label": "eu-central-1", "status": "danger", "tooltip": "down" }
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
Reuses the typed status enum from `list`. Auto-fits columns when `gridColumns` is omitted. Denser than a `list` when monitoring 12+ services at a glance.
|
||||||
|
|
||||||
|
### `stat` — sparkline (v2.7+ additive field)
|
||||||
|
```json
|
||||||
|
{ "type": "stat", "title": "Releases this month", "value": 4,
|
||||||
|
"color": "blue", "sparkline": [1, 2, 1, 3, 2, 4] }
|
||||||
|
```
|
||||||
|
Optional `sparkline: [Number]` renders a 1-line trend under the big number. Min 2 points, no max — tiny SVG path, cheap. Works on every existing `stat` widget without breaking older Scarf builds (they ignore the unknown field).
|
||||||
|
|
||||||
|
### Choosing a widget type — quick guide
|
||||||
|
|
||||||
|
- Counting things → `stat` (add `sparkline` if you have a recent trend).
|
||||||
|
- Progress toward a target → `progress`.
|
||||||
|
- Authored copy or short instructions → `text` (markdown).
|
||||||
|
- A report the cron job writes to disk → `markdown_file`.
|
||||||
|
- The most-recent run output of a cron job → `log_tail` or `cron_status`.
|
||||||
|
- A list of services / URLs / items with health → `list` (≤8 items) or `status_grid` (12+ items).
|
||||||
|
- Tabular data → `table` (or `chart` if it's numeric and you want trends).
|
||||||
|
- A live website or chart from a cron-generated PNG → `webview` (browsable) or `image` (static).
|
||||||
|
|
||||||
## Config Schema Design
|
## Config Schema Design
|
||||||
|
|
||||||
If the project needs user-configurable values, design a schema. Put it in `<project>/.scarf/manifest.json` with this shape:
|
If the project needs user-configurable values, design a schema. Put it in `<project>/.scarf/manifest.json` with this shape:
|
||||||
|
|||||||
Binary file not shown.
@@ -139,8 +139,8 @@
|
|||||||
"name": "Alan Wizemann",
|
"name": "Alan Wizemann",
|
||||||
"url": "https://github.com/awizemann"
|
"url": "https://github.com/awizemann"
|
||||||
},
|
},
|
||||||
"bundleSha256": "56ab97eeb45ab7b9e6715ce9c88ec2c953bf795698cd19628d300d5b8cffd475",
|
"bundleSha256": "f0f3e7960a07b66ffd84e521184750f826f2df521438c2a1a138b246da2731c5",
|
||||||
"bundleSize": 14610,
|
"bundleSize": 16487,
|
||||||
"category": "developer-tools",
|
"category": "developer-tools",
|
||||||
"config": null,
|
"config": null,
|
||||||
"contents": {
|
"contents": {
|
||||||
|
|||||||
+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-]*$")
|
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
|
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")
|
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
|
# Mirror of Swift's TemplateConfigField.FieldType. Order matters only
|
||||||
# for error messages that echo this set.
|
# 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,
|
template_dir,
|
||||||
f"dashboard widget {widget.get('title')!r} has unknown type {widget_type!r}"
|
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:
|
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()
|
_, errors = self._validate_all()
|
||||||
self.assertTrue(any("unknown type" in str(e) for e in errors), errors)
|
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):
|
def test_rejects_secret_in_bundle(self):
|
||||||
leaky = b"config:\n github_token: ghp_" + b"A" * 40 + b"\n"
|
leaky = b"config:\n github_token: ghp_" + b"A" * 40 + b"\n"
|
||||||
manifest = {
|
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