@jhytabest/plashboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openclaw.plugin.json +52 -0
- package/package.json +39 -0
- package/schema/fill-response.schema.json +14 -0
- package/schema/template.schema.json +124 -0
- package/scripts/dashboard_write.py +543 -0
- package/skills/plashboard-admin/SKILL.md +46 -0
- package/src/config.ts +73 -0
- package/src/fill-runner.ts +122 -0
- package/src/index.ts +7 -0
- package/src/json-pointer.ts +102 -0
- package/src/merge.test.ts +65 -0
- package/src/merge.ts +108 -0
- package/src/plugin.ts +272 -0
- package/src/publisher.ts +98 -0
- package/src/runtime.test.ts +163 -0
- package/src/runtime.ts +622 -0
- package/src/schema-validation.ts +35 -0
- package/src/stores.ts +127 -0
- package/src/types.ts +139 -0
- package/src/utils.ts +46 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, List, Optional
|
|
10
|
+
|
|
11
|
+
DATA_PATH = Path("/var/lib/openclaw/plash-data/dashboard.json")
|
|
12
|
+
|
|
13
|
+
ALLOWED_ALERT_SEVERITY = {"info", "warning", "critical"}
|
|
14
|
+
ALLOWED_CHART_KIND = {"sparkline", "bars"}
|
|
15
|
+
|
|
16
|
+
TARGET_VIEWPORT_HEIGHT = int(os.getenv("PLASH_TARGET_VIEWPORT_HEIGHT", "1080"))
|
|
17
|
+
LAYOUT_SAFETY_MARGIN = max(0, int(os.getenv("PLASH_LAYOUT_SAFETY_MARGIN", "24")))
|
|
18
|
+
LAYOUT_OVERFLOW_TOLERANCE = max(0, int(os.getenv("PLASH_LAYOUT_OVERFLOW_TOLERANCE", "40")))
|
|
19
|
+
WALLPAPER_GAP = 14
|
|
20
|
+
SECTION_GRID_GAP = 14
|
|
21
|
+
CARD_GRID_GAP = 10
|
|
22
|
+
ALERT_HEIGHT = 52
|
|
23
|
+
SECTION_CHROME_HEIGHT = 46
|
|
24
|
+
GRID_COLUMNS = 12
|
|
25
|
+
FRAME_GUTTER_TOP = max(0, int(os.getenv("PLASH_FRAME_GUTTER_TOP", "96")))
|
|
26
|
+
FRAME_GUTTER_BOTTOM = max(0, int(os.getenv("PLASH_FRAME_GUTTER_BOTTOM", "106")))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def now_iso() -> str:
|
|
30
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fail(msg: str) -> None:
|
|
34
|
+
raise SystemExit(f"validation failed: {msg}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_non_empty_string(value: object, path: str) -> None:
|
|
38
|
+
if not isinstance(value, str) or not value.strip():
|
|
39
|
+
fail(f"{path} must be a non-empty string")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_number(value: object, path: str) -> None:
|
|
43
|
+
if not isinstance(value, (int, float)):
|
|
44
|
+
fail(f"{path} must be a number")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def as_number(value: object, fallback: float) -> float:
|
|
48
|
+
if isinstance(value, (int, float)):
|
|
49
|
+
return float(value)
|
|
50
|
+
try:
|
|
51
|
+
return float(value)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return fallback
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def clamp(value: float, minimum: float, maximum: float) -> float:
|
|
57
|
+
return min(maximum, max(minimum, value))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_chart(value: object, path: str) -> None:
|
|
61
|
+
if not isinstance(value, dict):
|
|
62
|
+
fail(f"{path} must be an object")
|
|
63
|
+
|
|
64
|
+
for key in ("kind", "points"):
|
|
65
|
+
if key not in value:
|
|
66
|
+
fail(f"{path} missing key: {key}")
|
|
67
|
+
|
|
68
|
+
kind = value["kind"]
|
|
69
|
+
if kind not in ALLOWED_CHART_KIND:
|
|
70
|
+
fail(f"{path}.kind invalid: {kind}")
|
|
71
|
+
|
|
72
|
+
points = value["points"]
|
|
73
|
+
if not isinstance(points, list) or len(points) < 2:
|
|
74
|
+
fail(f"{path}.points must be a list with at least 2 values")
|
|
75
|
+
|
|
76
|
+
for i, point in enumerate(points):
|
|
77
|
+
validate_number(point, f"{path}.points[{i}]")
|
|
78
|
+
|
|
79
|
+
if "unit" in value and not isinstance(value["unit"], str):
|
|
80
|
+
fail(f"{path}.unit must be a string")
|
|
81
|
+
if "label" in value and not isinstance(value["label"], str):
|
|
82
|
+
fail(f"{path}.label must be a string")
|
|
83
|
+
|
|
84
|
+
for key in value:
|
|
85
|
+
if key not in {"kind", "points", "unit", "label"}:
|
|
86
|
+
fail(f"{path}.{key} is not supported")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_ui(payload: dict) -> None:
|
|
90
|
+
ui = payload["ui"]
|
|
91
|
+
if not isinstance(ui, dict):
|
|
92
|
+
fail("ui must be an object")
|
|
93
|
+
|
|
94
|
+
for key in ("timezone",):
|
|
95
|
+
if key not in ui:
|
|
96
|
+
fail(f"ui missing key: {key}")
|
|
97
|
+
|
|
98
|
+
validate_non_empty_string(ui["timezone"], "ui.timezone")
|
|
99
|
+
|
|
100
|
+
for key in ui:
|
|
101
|
+
if key not in {"timezone"}:
|
|
102
|
+
fail(f"ui.{key} is not supported")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_card(card: dict, path: str) -> None:
|
|
106
|
+
for key in ("id", "title"):
|
|
107
|
+
if key not in card:
|
|
108
|
+
fail(f"{path} missing key: {key}")
|
|
109
|
+
|
|
110
|
+
validate_non_empty_string(card["id"], f"{path}.id")
|
|
111
|
+
validate_non_empty_string(card["title"], f"{path}.title")
|
|
112
|
+
|
|
113
|
+
if "url" in card and not isinstance(card["url"], str):
|
|
114
|
+
fail(f"{path}.url must be a string")
|
|
115
|
+
if "description" in card and not isinstance(card["description"], str):
|
|
116
|
+
fail(f"{path}.description must be a string")
|
|
117
|
+
if "long_description" in card and not isinstance(card["long_description"], str):
|
|
118
|
+
fail(f"{path}.long_description must be a string")
|
|
119
|
+
if "chart" in card:
|
|
120
|
+
validate_chart(card["chart"], f"{path}.chart")
|
|
121
|
+
|
|
122
|
+
allowed_card_fields = {
|
|
123
|
+
"id",
|
|
124
|
+
"title",
|
|
125
|
+
"url",
|
|
126
|
+
"description",
|
|
127
|
+
"long_description",
|
|
128
|
+
"chart",
|
|
129
|
+
}
|
|
130
|
+
for key in card:
|
|
131
|
+
if key not in allowed_card_fields:
|
|
132
|
+
fail(f"{path}.{key} is not supported")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def compact_text(value: object) -> str:
|
|
136
|
+
if not isinstance(value, str):
|
|
137
|
+
return ""
|
|
138
|
+
return " ".join(value.split())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def card_has_chart(card: dict) -> bool:
|
|
142
|
+
chart = card.get("chart")
|
|
143
|
+
if not isinstance(chart, dict):
|
|
144
|
+
return False
|
|
145
|
+
kind = chart.get("kind")
|
|
146
|
+
points = chart.get("points")
|
|
147
|
+
return kind in ALLOWED_CHART_KIND and isinstance(points, list) and len(points) >= 2
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def estimate_text_lines(value: object, chars_per_line: int, max_lines: int) -> int:
|
|
151
|
+
if not isinstance(value, str):
|
|
152
|
+
return 0
|
|
153
|
+
cleaned = " ".join(value.split())
|
|
154
|
+
if not cleaned:
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
safe_chars_per_line = max(1, chars_per_line)
|
|
158
|
+
lines = max(1, math.ceil(len(cleaned) / safe_chars_per_line))
|
|
159
|
+
return min(max_lines, lines)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def estimate_card_height(card: dict, card_span: int, section_span: int) -> int:
|
|
163
|
+
safe_card_span = int(clamp(card_span, 3, GRID_COLUMNS))
|
|
164
|
+
safe_section_span = int(clamp(section_span, 3, GRID_COLUMNS))
|
|
165
|
+
width_scale = (safe_section_span / 4) * (safe_card_span / GRID_COLUMNS)
|
|
166
|
+
chars_per_line = max(14, round(58 * width_scale))
|
|
167
|
+
|
|
168
|
+
base = 50
|
|
169
|
+
base += estimate_text_lines(card.get("title"), chars_per_line, 2) * 12
|
|
170
|
+
base += estimate_text_lines(card.get("description"), chars_per_line, 3) * 12
|
|
171
|
+
base += estimate_text_lines(card.get("url"), chars_per_line, 2) * 11
|
|
172
|
+
base += estimate_text_lines(card.get("long_description"), chars_per_line, 4) * 12
|
|
173
|
+
|
|
174
|
+
if card_has_chart(card):
|
|
175
|
+
base += 98
|
|
176
|
+
if card.get("long_description"):
|
|
177
|
+
base += 6
|
|
178
|
+
|
|
179
|
+
return int(clamp(base, 82, 288))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def choose_card_span(card: dict, section_span: int) -> int:
|
|
183
|
+
has_chart = card_has_chart(card)
|
|
184
|
+
title_length = len(compact_text(card.get("title")))
|
|
185
|
+
description_length = len(compact_text(card.get("description")))
|
|
186
|
+
long_length = len(compact_text(card.get("long_description")))
|
|
187
|
+
density = title_length + description_length + long_length
|
|
188
|
+
|
|
189
|
+
if has_chart and long_length > 70:
|
|
190
|
+
return 12
|
|
191
|
+
if has_chart:
|
|
192
|
+
return 6 if section_span >= 6 else 12
|
|
193
|
+
if long_length > 120:
|
|
194
|
+
return 12
|
|
195
|
+
if density > 170:
|
|
196
|
+
return 6
|
|
197
|
+
if density < 48 and not card.get("url"):
|
|
198
|
+
return 4
|
|
199
|
+
return 6
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def pack_rows(entries: List[dict], recalc_height_for_span: Optional[Callable[[dict, int], int]] = None) -> List[List[dict]]:
|
|
203
|
+
remaining = [dict(entry) for entry in entries]
|
|
204
|
+
remaining.sort(key=lambda entry: (-entry["height"], entry["importance"], entry["stable"]))
|
|
205
|
+
rows: List[List[dict]] = []
|
|
206
|
+
|
|
207
|
+
while remaining:
|
|
208
|
+
row: List[dict] = []
|
|
209
|
+
used = 0
|
|
210
|
+
row_height = 0
|
|
211
|
+
seed = remaining.pop(0)
|
|
212
|
+
|
|
213
|
+
row.append(seed)
|
|
214
|
+
used += seed["span"]
|
|
215
|
+
row_height = max(row_height, seed["height"])
|
|
216
|
+
|
|
217
|
+
while used < GRID_COLUMNS:
|
|
218
|
+
space = GRID_COLUMNS - used
|
|
219
|
+
best_index = -1
|
|
220
|
+
best_score: Optional[float] = None
|
|
221
|
+
|
|
222
|
+
for index, candidate in enumerate(remaining):
|
|
223
|
+
if candidate["span"] > space:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
leftover_after = space - candidate["span"]
|
|
227
|
+
height_delta = abs(candidate["height"] - row_height)
|
|
228
|
+
score = leftover_after * 5.0 + height_delta * 0.08 + candidate["importance"] * 0.015
|
|
229
|
+
if candidate["span"] == space:
|
|
230
|
+
score -= 3.0
|
|
231
|
+
|
|
232
|
+
if best_score is None or score < best_score:
|
|
233
|
+
best_score = score
|
|
234
|
+
best_index = index
|
|
235
|
+
|
|
236
|
+
if best_index < 0:
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
picked = remaining.pop(best_index)
|
|
240
|
+
row.append(picked)
|
|
241
|
+
used += picked["span"]
|
|
242
|
+
row_height = max(row_height, picked["height"])
|
|
243
|
+
|
|
244
|
+
if used < GRID_COLUMNS and row:
|
|
245
|
+
leftover = GRID_COLUMNS - used
|
|
246
|
+
last = dict(row[-1])
|
|
247
|
+
last["span"] += leftover
|
|
248
|
+
if recalc_height_for_span:
|
|
249
|
+
last["height"] = recalc_height_for_span(last["item"], last["span"])
|
|
250
|
+
row[-1] = last
|
|
251
|
+
|
|
252
|
+
rows.append(row)
|
|
253
|
+
|
|
254
|
+
return rows
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def visible_cards(section: dict) -> List[dict]:
|
|
258
|
+
raw_cards = section.get("cards", [])
|
|
259
|
+
if not isinstance(raw_cards, list):
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
cards = []
|
|
263
|
+
for index, card in enumerate(raw_cards):
|
|
264
|
+
if not isinstance(card, dict):
|
|
265
|
+
continue
|
|
266
|
+
if not isinstance(card.get("title"), str) or not card.get("title"):
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
next_card = dict(card)
|
|
270
|
+
next_card["_importance"] = 100
|
|
271
|
+
next_card["_stable"] = f"{index:04d}"
|
|
272
|
+
cards.append(next_card)
|
|
273
|
+
return cards
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def pack_cards(cards: List[dict], section_span: int) -> dict:
|
|
277
|
+
entries = []
|
|
278
|
+
for index, card in enumerate(cards):
|
|
279
|
+
span = choose_card_span(card, section_span)
|
|
280
|
+
entries.append(
|
|
281
|
+
{
|
|
282
|
+
"item": card,
|
|
283
|
+
"span": span,
|
|
284
|
+
"height": estimate_card_height(card, span, section_span),
|
|
285
|
+
"importance": card["_importance"],
|
|
286
|
+
"stable": f"{card['_stable']}-{index}",
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
card_rows = pack_rows(entries, lambda card, next_span: estimate_card_height(card, next_span, section_span))
|
|
291
|
+
row_heights = [max(entry["height"] for entry in row) for row in card_rows]
|
|
292
|
+
cards_height = sum(row_heights) + CARD_GRID_GAP * max(0, len(row_heights) - 1)
|
|
293
|
+
packed_cards = []
|
|
294
|
+
|
|
295
|
+
for row in card_rows:
|
|
296
|
+
for entry in row:
|
|
297
|
+
packed = dict(entry["item"])
|
|
298
|
+
packed["_computed_span"] = entry["span"]
|
|
299
|
+
packed_cards.append(packed)
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
"cards": packed_cards,
|
|
303
|
+
"row_count": len(card_rows),
|
|
304
|
+
"estimated_height": SECTION_CHROME_HEIGHT + cards_height,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def section_span_candidates(cards: List[dict]) -> List[int]:
|
|
309
|
+
chart_count = sum(1 for card in cards if card_has_chart(card))
|
|
310
|
+
long_count = sum(1 for card in cards if len(compact_text(card.get("long_description"))) > 70)
|
|
311
|
+
candidates = {4, 6}
|
|
312
|
+
|
|
313
|
+
if len(cards) <= 2 and chart_count == 0 and long_count == 0:
|
|
314
|
+
candidates.add(3)
|
|
315
|
+
if len(cards) >= 5 or chart_count >= 2 or long_count >= 2:
|
|
316
|
+
candidates.add(8)
|
|
317
|
+
if len(cards) >= 7:
|
|
318
|
+
candidates.add(12)
|
|
319
|
+
|
|
320
|
+
return sorted(candidates)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def choose_section_layout(section: dict, section_index: int) -> Optional[dict]:
|
|
324
|
+
cards = visible_cards(section)
|
|
325
|
+
if not cards:
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
section_importance = min((card["_importance"] for card in cards), default=100)
|
|
329
|
+
section_stable = f"{section_index:04d}"
|
|
330
|
+
best_layout: Optional[dict] = None
|
|
331
|
+
best_score: Optional[float] = None
|
|
332
|
+
|
|
333
|
+
for section_span in section_span_candidates(cards):
|
|
334
|
+
packed = pack_cards(cards, section_span)
|
|
335
|
+
score = packed["estimated_height"] * (1 + section_span / 18) + packed["row_count"] * 8
|
|
336
|
+
candidate = {
|
|
337
|
+
"section": section,
|
|
338
|
+
"span": section_span,
|
|
339
|
+
"height": packed["estimated_height"],
|
|
340
|
+
"importance": section_importance,
|
|
341
|
+
"stable": section_stable,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if best_score is None or score < best_score:
|
|
345
|
+
best_score = score
|
|
346
|
+
best_layout = candidate
|
|
347
|
+
|
|
348
|
+
return best_layout
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def pack_sections(sections: List[dict]) -> List[List[dict]]:
|
|
352
|
+
return pack_rows(sections)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def estimate_layout_height(payload: dict) -> int:
|
|
356
|
+
alerts = payload.get("alerts", []) if isinstance(payload.get("alerts"), list) else []
|
|
357
|
+
visible_alerts = [alert for alert in alerts if isinstance(alert, dict) and alert.get("message")]
|
|
358
|
+
alerts_height = ALERT_HEIGHT if visible_alerts else 0
|
|
359
|
+
|
|
360
|
+
sections = payload.get("sections", []) if isinstance(payload.get("sections"), list) else []
|
|
361
|
+
section_layouts = []
|
|
362
|
+
for section_index, section in enumerate(sections):
|
|
363
|
+
if not isinstance(section, dict):
|
|
364
|
+
continue
|
|
365
|
+
section_layout = choose_section_layout(section, section_index)
|
|
366
|
+
if section_layout:
|
|
367
|
+
section_layouts.append(section_layout)
|
|
368
|
+
|
|
369
|
+
if not section_layouts:
|
|
370
|
+
return alerts_height
|
|
371
|
+
|
|
372
|
+
section_rows = pack_sections(section_layouts)
|
|
373
|
+
section_row_heights = [max(entry["height"] for entry in row) for row in section_rows]
|
|
374
|
+
|
|
375
|
+
sections_height = sum(section_row_heights) + SECTION_GRID_GAP * max(0, len(section_row_heights) - 1)
|
|
376
|
+
between = WALLPAPER_GAP if alerts_height and sections_height else 0
|
|
377
|
+
return alerts_height + between + sections_height
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def drop_candidate_ids(payload: dict, limit: int = 8) -> List[str]:
|
|
381
|
+
candidates = []
|
|
382
|
+
sections = payload.get("sections", []) if isinstance(payload.get("sections"), list) else []
|
|
383
|
+
|
|
384
|
+
for section in sections:
|
|
385
|
+
if not isinstance(section, dict):
|
|
386
|
+
continue
|
|
387
|
+
for card in visible_cards(section):
|
|
388
|
+
card_id = card.get("id")
|
|
389
|
+
if not isinstance(card_id, str) or not card_id:
|
|
390
|
+
continue
|
|
391
|
+
candidates.append(card_id)
|
|
392
|
+
|
|
393
|
+
return list(reversed(candidates))[:limit]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def validate_layout_budget(payload: dict) -> None:
|
|
397
|
+
available = max(0, TARGET_VIEWPORT_HEIGHT - FRAME_GUTTER_TOP - FRAME_GUTTER_BOTTOM - LAYOUT_SAFETY_MARGIN)
|
|
398
|
+
required = estimate_layout_height(payload)
|
|
399
|
+
|
|
400
|
+
if required <= available + LAYOUT_OVERFLOW_TOLERANCE:
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
overflow = required - available
|
|
404
|
+
candidates = drop_candidate_ids(payload)
|
|
405
|
+
candidate_text = ", ".join(candidates) if candidates else "(none)"
|
|
406
|
+
fail(
|
|
407
|
+
f"layout budget exceeded by {overflow}px (required={required}px, available={available}px, safety={LAYOUT_SAFETY_MARGIN}px, tolerance={LAYOUT_OVERFLOW_TOLERANCE}px). "
|
|
408
|
+
f"Reduce card volume or shorten card content. Suggested drop candidates: {candidate_text}"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def validate(payload: dict) -> None:
|
|
413
|
+
if not isinstance(payload, dict):
|
|
414
|
+
fail("root must be an object")
|
|
415
|
+
|
|
416
|
+
for key in ("title", "ui", "sections"):
|
|
417
|
+
if key not in payload:
|
|
418
|
+
fail(f"missing required key: {key}")
|
|
419
|
+
|
|
420
|
+
if "version" in payload:
|
|
421
|
+
version = payload["version"]
|
|
422
|
+
if not isinstance(version, str) or not version.startswith("3."):
|
|
423
|
+
fail('version must be a string starting with "3."')
|
|
424
|
+
|
|
425
|
+
if "generated_at" in payload and not isinstance(payload["generated_at"], str):
|
|
426
|
+
fail("generated_at must be a string")
|
|
427
|
+
|
|
428
|
+
validate_non_empty_string(payload["title"], "title")
|
|
429
|
+
if "summary" in payload and not isinstance(payload["summary"], str):
|
|
430
|
+
fail("summary must be a string")
|
|
431
|
+
|
|
432
|
+
validate_ui(payload)
|
|
433
|
+
|
|
434
|
+
sections = payload["sections"]
|
|
435
|
+
if not isinstance(sections, list):
|
|
436
|
+
fail("sections must be a list")
|
|
437
|
+
|
|
438
|
+
for i, section in enumerate(sections):
|
|
439
|
+
section_path = f"sections[{i}]"
|
|
440
|
+
if not isinstance(section, dict):
|
|
441
|
+
fail(f"{section_path} must be an object")
|
|
442
|
+
|
|
443
|
+
for key in ("id", "label", "cards"):
|
|
444
|
+
if key not in section:
|
|
445
|
+
fail(f"{section_path} missing key: {key}")
|
|
446
|
+
|
|
447
|
+
validate_non_empty_string(section["id"], f"{section_path}.id")
|
|
448
|
+
validate_non_empty_string(section["label"], f"{section_path}.label")
|
|
449
|
+
|
|
450
|
+
cards = section["cards"]
|
|
451
|
+
if not isinstance(cards, list):
|
|
452
|
+
fail(f"{section_path}.cards must be a list")
|
|
453
|
+
|
|
454
|
+
for j, card in enumerate(cards):
|
|
455
|
+
card_path = f"{section_path}.cards[{j}]"
|
|
456
|
+
if not isinstance(card, dict):
|
|
457
|
+
fail(f"{card_path} must be an object")
|
|
458
|
+
validate_card(card, card_path)
|
|
459
|
+
|
|
460
|
+
for key in section:
|
|
461
|
+
if key not in {"id", "label", "cards"}:
|
|
462
|
+
fail(f"{section_path}.{key} is not supported")
|
|
463
|
+
|
|
464
|
+
alerts = payload.get("alerts", [])
|
|
465
|
+
if not isinstance(alerts, list):
|
|
466
|
+
fail("alerts must be a list")
|
|
467
|
+
|
|
468
|
+
for i, alert in enumerate(alerts):
|
|
469
|
+
path = f"alerts[{i}]"
|
|
470
|
+
if not isinstance(alert, dict):
|
|
471
|
+
fail(f"{path} must be an object")
|
|
472
|
+
for key in ("id", "severity", "message"):
|
|
473
|
+
if key not in alert:
|
|
474
|
+
fail(f"{path} missing key: {key}")
|
|
475
|
+
|
|
476
|
+
validate_non_empty_string(alert["id"], f"{path}.id")
|
|
477
|
+
validate_non_empty_string(alert["message"], f"{path}.message")
|
|
478
|
+
|
|
479
|
+
if alert["severity"] not in ALLOWED_ALERT_SEVERITY:
|
|
480
|
+
fail(f"{path}.severity invalid: {alert['severity']}")
|
|
481
|
+
|
|
482
|
+
for key in alert:
|
|
483
|
+
if key not in {"id", "severity", "message"}:
|
|
484
|
+
fail(f"{path}.{key} is not supported")
|
|
485
|
+
|
|
486
|
+
for key in payload:
|
|
487
|
+
if key not in {"version", "generated_at", "title", "summary", "ui", "sections", "alerts"}:
|
|
488
|
+
fail(f"{key} is not supported")
|
|
489
|
+
|
|
490
|
+
validate_layout_budget(payload)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def atomic_write(path: Path, payload: dict) -> None:
|
|
494
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as tmp:
|
|
496
|
+
json.dump(payload, tmp, indent=2)
|
|
497
|
+
tmp.write("\n")
|
|
498
|
+
tmp_path = Path(tmp.name)
|
|
499
|
+
|
|
500
|
+
os.replace(tmp_path, path)
|
|
501
|
+
os.chmod(path, 0o664)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def main() -> None:
|
|
505
|
+
parser = argparse.ArgumentParser(description="Validate and atomically replace dashboard.json")
|
|
506
|
+
parser.add_argument("--input", required=True, help="Path to next dashboard JSON")
|
|
507
|
+
parser.add_argument("--output", help="Output dashboard JSON path (default: live dashboard path)")
|
|
508
|
+
parser.add_argument(
|
|
509
|
+
"--validate-only",
|
|
510
|
+
action="store_true",
|
|
511
|
+
help="Validate payload only and do not write output file",
|
|
512
|
+
)
|
|
513
|
+
parser.add_argument(
|
|
514
|
+
"--touch-generated-at",
|
|
515
|
+
action="store_true",
|
|
516
|
+
help="Force generated_at to current UTC timestamp",
|
|
517
|
+
)
|
|
518
|
+
args = parser.parse_args()
|
|
519
|
+
|
|
520
|
+
input_path = Path(args.input)
|
|
521
|
+
payload = json.loads(input_path.read_text(encoding="utf-8"))
|
|
522
|
+
payload["version"] = "3.0"
|
|
523
|
+
if args.touch_generated_at or "generated_at" not in payload:
|
|
524
|
+
payload["generated_at"] = now_iso()
|
|
525
|
+
|
|
526
|
+
validate(payload)
|
|
527
|
+
if not args.validate_only:
|
|
528
|
+
output_path = Path(args.output) if args.output else DATA_PATH
|
|
529
|
+
atomic_write(output_path, payload)
|
|
530
|
+
else:
|
|
531
|
+
output_path = None
|
|
532
|
+
|
|
533
|
+
sections = payload.get("sections", [])
|
|
534
|
+
cards = sum(len(section.get("cards", [])) for section in sections if isinstance(section, dict))
|
|
535
|
+
alerts = len(payload.get("alerts", [])) if isinstance(payload.get("alerts", []), list) else 0
|
|
536
|
+
if output_path is None:
|
|
537
|
+
print(f"validated payload (sections={len(sections)} cards={cards} alerts={alerts})")
|
|
538
|
+
else:
|
|
539
|
+
print(f"wrote {output_path} (sections={len(sections)} cards={cards} alerts={alerts})")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
if __name__ == "__main__":
|
|
543
|
+
main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plashboard-admin
|
|
3
|
+
description: Manage plashboard templates, activation, copy/delete, run-now triggers, and display profile using plashboard plugin tools.
|
|
4
|
+
command-dispatch: tool
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Plashboard Admin
|
|
8
|
+
|
|
9
|
+
Use this skill for plashboard runtime administration.
|
|
10
|
+
|
|
11
|
+
## Use Cases
|
|
12
|
+
- Create, update, copy, delete, and validate dashboard templates.
|
|
13
|
+
- Activate a template as the live dashboard source.
|
|
14
|
+
- Trigger immediate runs.
|
|
15
|
+
- Inspect runtime status and schedule health.
|
|
16
|
+
- Adjust display profile for layout budget checks.
|
|
17
|
+
|
|
18
|
+
## Required Tooling
|
|
19
|
+
Always use plugin tools:
|
|
20
|
+
- `plashboard_init`
|
|
21
|
+
- `plashboard_template_create`
|
|
22
|
+
- `plashboard_template_update`
|
|
23
|
+
- `plashboard_template_list`
|
|
24
|
+
- `plashboard_template_activate`
|
|
25
|
+
- `plashboard_template_copy`
|
|
26
|
+
- `plashboard_template_delete`
|
|
27
|
+
- `plashboard_template_validate`
|
|
28
|
+
- `plashboard_run_now`
|
|
29
|
+
- `plashboard_status`
|
|
30
|
+
- `plashboard_display_profile_set`
|
|
31
|
+
|
|
32
|
+
## Guardrails
|
|
33
|
+
- Never edit `/var/lib/openclaw/plash-data/dashboard.json` directly.
|
|
34
|
+
- Never edit template/state/run JSON files directly.
|
|
35
|
+
- Never perform Docker, Tailscale, or systemd operations.
|
|
36
|
+
- Never ask the model to generate full dashboard structure when filling values.
|
|
37
|
+
|
|
38
|
+
## Command Shortcuts
|
|
39
|
+
- `/plashboard init`
|
|
40
|
+
- `/plashboard status`
|
|
41
|
+
- `/plashboard list`
|
|
42
|
+
- `/plashboard activate <template-id>`
|
|
43
|
+
- `/plashboard copy <source-template-id> <new-template-id> [new-name] [activate]`
|
|
44
|
+
- `/plashboard delete <template-id>`
|
|
45
|
+
- `/plashboard run <template-id>`
|
|
46
|
+
- `/plashboard set-display <width> <height> <safe_top> <safe_bottom>`
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import type { DisplayProfile, PlashboardConfig } from './types.js';
|
|
4
|
+
|
|
5
|
+
const THIS_DIR = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const DEFAULT_WRITER_PATH = join(THIS_DIR, '..', 'scripts', 'dashboard_write.py');
|
|
7
|
+
|
|
8
|
+
function hostTimezone(): string {
|
|
9
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function asNumber(value: unknown, fallback: number): number {
|
|
13
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
14
|
+
if (typeof value === 'string' && value.trim()) {
|
|
15
|
+
const parsed = Number(value);
|
|
16
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
17
|
+
}
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function asString(value: unknown, fallback: string): string {
|
|
22
|
+
return typeof value === 'string' && value.trim() ? value : fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function asObject(value: unknown): Record<string, unknown> {
|
|
26
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
27
|
+
? (value as Record<string, unknown>)
|
|
28
|
+
: {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveDisplayProfile(raw: unknown): DisplayProfile {
|
|
32
|
+
const data = asObject(raw);
|
|
33
|
+
return {
|
|
34
|
+
width_px: Math.max(320, Math.floor(asNumber(data.width_px, 1920))),
|
|
35
|
+
height_px: Math.max(240, Math.floor(asNumber(data.height_px, 1080))),
|
|
36
|
+
safe_top_px: Math.max(0, Math.floor(asNumber(data.safe_top_px, 96))),
|
|
37
|
+
safe_bottom_px: Math.max(0, Math.floor(asNumber(data.safe_bottom_px, 106))),
|
|
38
|
+
safe_side_px: Math.max(0, Math.floor(asNumber(data.safe_side_px, 28))),
|
|
39
|
+
layout_safety_margin_px: Math.max(0, Math.floor(asNumber(data.layout_safety_margin_px, 24)))
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveConfig(api: unknown): PlashboardConfig {
|
|
44
|
+
const apiObj = asObject(api);
|
|
45
|
+
const pluginConfig = asObject(
|
|
46
|
+
(apiObj.pluginConfig as unknown)
|
|
47
|
+
?? asObject(asObject(asObject(apiObj.config).plugins).entries).plashboard
|
|
48
|
+
);
|
|
49
|
+
const raw = asObject(pluginConfig.config ?? pluginConfig);
|
|
50
|
+
|
|
51
|
+
const dataDir = asString(raw.data_dir, '/var/lib/openclaw/plash-data');
|
|
52
|
+
const outputPath = asString(raw.dashboard_output_path, join(dataDir, 'dashboard.json'));
|
|
53
|
+
const fillProviderRaw = asString(raw.fill_provider, 'mock');
|
|
54
|
+
const fillProvider = fillProviderRaw === 'command' ? 'command' : 'mock';
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
data_dir: dataDir,
|
|
58
|
+
timezone: asString(raw.timezone, hostTimezone()),
|
|
59
|
+
scheduler_tick_seconds: Math.max(5, Math.floor(asNumber(raw.scheduler_tick_seconds, 30))),
|
|
60
|
+
max_parallel_runs: Math.max(1, Math.floor(asNumber(raw.max_parallel_runs, 1))),
|
|
61
|
+
default_retry_count: Math.max(0, Math.floor(asNumber(raw.default_retry_count, 1))),
|
|
62
|
+
retry_backoff_seconds: Math.max(1, Math.floor(asNumber(raw.retry_backoff_seconds, 20))),
|
|
63
|
+
session_timeout_seconds: Math.max(10, Math.floor(asNumber(raw.session_timeout_seconds, 90))),
|
|
64
|
+
fill_provider: fillProvider,
|
|
65
|
+
fill_command: typeof raw.fill_command === 'string' ? raw.fill_command : undefined,
|
|
66
|
+
python_bin: asString(raw.python_bin, 'python3'),
|
|
67
|
+
writer_script_path: asString(raw.writer_script_path, DEFAULT_WRITER_PATH),
|
|
68
|
+
dashboard_output_path: outputPath,
|
|
69
|
+
layout_overflow_tolerance_px: Math.max(0, Math.floor(asNumber(raw.layout_overflow_tolerance_px, 40))),
|
|
70
|
+
display_profile: resolveDisplayProfile(raw.display_profile),
|
|
71
|
+
model_defaults: asObject(raw.model_defaults) as PlashboardConfig['model_defaults']
|
|
72
|
+
};
|
|
73
|
+
}
|