@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.
@@ -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
+ }