Files
BAT/CONTRACT.md
b.peck 83b23431c6 Phase 2: dashboard tabs + popups, chart fixes, shell relocation
Built via parallel Workflow (5 builders + adversarial verifiers), then
live-rendered and fixed in a headless browser against the running gateway.

Tabs (PrimeControls/Tabs/*):
- Overview: priority cards, KPI status row, alarm-rate timeline, insights, top-5
- Analysis: hour x day heatmap, priority donut vs ISA 80/15/5, MTTA/MTTR, floods
- BadActors: chattering/standing/fleeting tables, focus chip, AlarmDetail wiring
- Journal: paginated getJournalPage table, CSV export, EventDetail wiring

Popups (PrimeControls/Popups/*): HealthScore, ShiftReport, AlarmDetail, EventDetail

Chart fixes (verified rendering in-browser):
- Overview timeline: seeded dataSources.rate, switched to static fills, split
  flood bins into their own red stacked series (per-point deriveFieldsFromData
  fill was crashing render with React.cloneElement null).
- BadActors: removed the dual-axis Pareto XY chart (scaling was unusable);
  replaced with a ranked "Top Sources" table (share-of-total progress bar +
  cumulative %). Same insight, no axis scaling to break.

Structural / hygiene:
- Relocated Header + FilterBar from Dashboard/ to Shell/ and updated the two
  embed paths. A Perspective view cannot resolve sub-views nested inside another
  view's folder, so both were rendering as "view does not exist" placeholders.
- Set persistent:false on all view input params. persistent:true caused the
  gateway to bake the live bundle (with sim tag paths) into Tabs/Analysis on
  render; nulled that baked data and reset the session query range to 0.

lint_project.py: 0 failures / 0 warnings.

Verified in-browser: Overview, BadActors. NOT yet live-verified: Analysis,
Journal, and the four popups (schema-verified only). Header content is next
phase (renders now, but a benign cloneElement console warning remains).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:45:17 -05:00

227 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CONTRACT.md — PrimeBAT build contract (FROZEN after Gate G1)
Every builder/verifier agent works from this file. Do not deviate; changes require the
integrator to edit this file first. Repo: `/home/bpeck/git/buildathon`. Project root:
`ignition/gateway/projects/PrimeBAT/`. Reload: `python3 tools/provision.py scan-projects`.
Lint: `python3 tools/lint_project.py` (must exit 0). Component prop schemas (ground truth
for every prop name): `/tmp/claude-1000/-home-bpeck-git-buildathon/a7f89b44-360c-4f23-ab63-503f3c25b53b/scratchpad/schemas/components/<component-id>.schema.json`.
Format exemplar (READ IT before writing any view.json):
`/home/bpeck/git/primebench/ignition/gateway/projects/PrimeBench/com.inductiveautomation.perspective/views/ForceRow/view.json`.
## Hard rules (contest + platform)
1. Jython 2.7 everywhere (project scripts, transforms, event scripts): **NO f-strings**
(`%` formatting only), no `typing`, no `statistics`. Scripts in view.json are inline
JSON strings, tab-indented (`\t`), first char of every script/transform line block is a tab.
2. **Java exceptions bypass `except Exception`** in Jython. Gateway-facing defensive code
uses bare `except:` + `sys.exc_info()` (probe-verified).
3. NO tag bindings in PrimeBAT. NO SQL / `system.db.*`. NO `journalName=` literals.
Banned strings (lint-enforced): `[default]`, `BuildathonSim`, `Buildathon_DB`,
`alarmsim`, `SELECT`, f-string prefixes.
4. All views/styles/scripts under `PrimeControls/` namespaces. Single page `/`
`PrimeControls/Dashboard`. `sharedDocks` stays `{}` (docked views prohibited).
5. Every view tolerates `bundle: null` / empty arrays — no binding may throw on empty.
Empty-window UX = `PrimeControls/Components/EmptyState` embed, not a broken chart.
6. resource.json boilerplate for every view:
`{"scope":"G","version":1,"restricted":false,"overridable":true,"files":["view.json"],"attributes":{}}`
(style classes: `files:["style.json"]`; script modules: scope `"A"`, `attributes:{"hintScope":2}`).
7. Numbers/durations/timestamps rendered through `PrimeControls.fmt` helpers (consistency).
## Probe-verified journal API facts (do not re-derive)
- `system.alarm.queryJournal(startDate=<ms|Date>, endDate=<ms|Date>)` — epoch millis
accepted directly; omit `journalName` (uses the single configured journal); nonexistent
name raises java `IllegalArgumentException` (catch with bare except).
- Result `AlarmQueryResultImpl`: iterable, `len()` works. One entry per **transition**;
entries of one alarm instance share `str(evt.getId())` (uuid).
- `str(evt.getState())` is compound: `"Active, Unacknowledged"`, `"Cleared, Acknowledged"`, etc.
Transition kind per row: activation row state starts `"Active"` and row's
`getActiveData()` non-null; clear row starts `"Cleared"` (`getClearedData()` non-null);
ack row has `getAckData()` non-null / state contains `", Acknowledged"`.
Per-instance: `active_ms` = min ts of Active rows; `clear_ms` = min ts of Cleared rows;
`ack_ms` = min ts of rows with ack marker.
- `evt.get("eventTime")` → java Date; `.getTime()` → ms. `evt.get("eventType")` is None (unused).
- `evt.getDisplayPath()` = `""` when unconfigured → fall back to parsing `getSource()`
(`prov:default:/tag:FOLDER/SUB/TAG:/alm:ALARMNAME`).
- `evt.getPriority()` → AlarmPriority enum: `.ordinal()` → 0..4, `str()` → name.
- System rows: `source == "evt:System Startup"` etc., `evt.get("isSystemEvent")` True.
`includeSystem`-style kwargs are ACCEPTED BUT IGNORED — always filter client-side.
- Ack user field: `evt.get("ackUserName")` (unicode, may be `u''`). `ackUser` is None.
- `system.alarm.queryStatus(state=["ActiveUnacked","ActiveAcked"])` works; entries have
`getActiveData()` with eventTime. `system.alarm.getShelvedPaths()` → list.
## Session props (only these exist; NO agent may add any)
`session.custom.PrimeControls`:
```json
{"query": {"startMs": 0, "endMs": 0, "rangePreset": "8h", "priorities": [], "areas": [],
"states": [], "search": "", "refreshToken": 0},
"ui": {"selectedTab": 0, "badActorFocus": ""}}
```
- `rangePreset``4h|8h|24h|7d|custom`. Header preset dropdown writes startMs/endMs/rangePreset.
- Filters (`priorities` = int levels 04, `areas` = strings, `states`
`["active","cleared","acked","unacked"]`, `search` substring) are written by FilterBar;
**Apply button bumps `refreshToken`** (filters do NOT auto-refetch).
- `ui.selectedTab`: 0 Overview · 1 Analysis · 2 Bad Actors · 3 Journal.
- `ui.badActorFocus`: source string; set by Overview top-5 click; BadActors highlights + clears.
## Data layer (script package `PrimeControls`, resource paths `ignition/script-python/PrimeControls/<mod>/code.py`)
Modules: `calc` (pure), `fmt` (pure), `alarms` (gateway adapter). Pure modules run under
CPython 3 for pytest AND Jython 2.7 (`from __future__ import division, print_function`).
### Perspective entry points (exact signatures)
```python
PrimeControls.alarms.getDashboardBundle(startMs, endMs, options=None) # -> bundle dict (below)
PrimeControls.alarms.getJournalPage(startMs, endMs, filters=None, page=0, pageSize=50)
# filters: {"priorities":[int], "areas":[str], "states":[str], "search":str}
# -> {"rows":[{"id","time_ms","time_label","source","label","area","state","state_label",
# "priority","priority_name","ack_user"}], "total":int, "page":int,
# "page_size":int, "truncated":bool, "error":None|str}
PrimeControls.alarms.journalCsv(startMs, endMs, filters=None, maxRows=10000) # -> CSV string
PrimeControls.alarms.getSourceDetail(source, startMs, endMs)
# -> {"label","area","events":[journal-page rows],"daily":[{"t0","count"}],
# "stats":{"count","avg_tta_ms","avg_active_ms","fleeting_count",
# "top_ack_users":[{"user","count"}]},"error":None|str}
PrimeControls.alarms.getEventData(eventId, aroundMs, source) # -> {"props":[{"name","value"}],"error":None|str}
PrimeControls.alarms.getShiftReportText(shiftHours=8, anchorHour=0, options=None)
# -> {"text":str, "start_ms":int, "end_ms":int, "label":str}
PrimeControls.fmt.dur(ms) / num(v, dec=0) / pct(v, dec=0, signed=False) / clock(ms) / day_clock(ms)
PrimeControls.fmt.delta_chip(d) # d = a kpi delta dict -> {"arrow","text","good"}
```
### Bundle schema (`getDashboardBundle` return — every key ALWAYS present)
```
meta: {start_ms, end_ms, prior_start_ms, now_ms, window_hours, event_count,
activation_count, dropped_rows, truncated, correlation_mode,
standing_mode, areas:[str], priorities_seen:[{"value":int,"label":str}],
source_count, error:None|str, opts:{...effective thresholds...}}
kpis: {activations, rate_per_hr, active_now, unacked_now, shelved_now, mtta_ms,
mttr_ms, flood_pct, flood_count, chatter_count, standing_count, fleeting_count}
# each value = {"value", "prior", "delta_pct", "dir": "up|down|flat|new|none", "good": bool|None}
health: {grade:"A".."F"|None, score:float|None,
subs:[{key,label,score:float|None,weight,detail}]} # keys: rate,flood,chatter,standing,priority
insights: [{severity:int(0 crit..3 info), icon:str, text:str, tab:str}]
rate: {bins:[{t0,t1,count,flood:bool}], bin_ms, target_per_bin, flood_threshold, max_count}
floods: {episodes:[{start_ms,end_ms,duration_ms,event_count,peak_bin_count,
top_source_label,top_source_count,top_area}], pct_time_in_flood}
priority: {raw:[{name,level,count}], buckets:{low,medium,high,other},
pct:{low,medium,high}, target:{low:80.0,medium:15.0,high:5.0}, sum_abs_dev}
heatmap: {rows:[[int]*24]*7, row_labels:["Mon".."Sun"], max_count, total}
mtta_mttr: {mtta:{mean_ms,median_ms,count}, mttr:{mean_ms,median_ms,count},
trend:[{t0,mtta_ms|None,mttr_ms|None,ack_n,clear_n}]}
pareto: {total_activations, rows:[{rank,source,label,area,priority_name,count,pct,cum_pct}]}
top_sources: [first 5 pareto rows]
chattering: [{source,label,area,priority_name,count,per_hour,median_gap_s}]
fleeting: {total, sources:[{source,label,area,priority_name,count,median_s}]}
standing: {mode, count, rows:[{source,label,area,priority_name,active_ms,age_ms,age_h,unacked}]}
active_now: [{source,label,area,priority_name,active_ms,age_ms,unacked}] # capped 200
```
Thresholds (surfaced in `meta.opts`): bins 10 min; flood >10/10min (end hysteresis 5);
ISA target 6/hr; chatter >10/hr AND median gap ≤120 s (min 5); fleeting <10 s;
standing >24 h; deltas flat band ±5%; caps: 50 000 events, 5000 bins, lists ≤50 rows.
## Binding patterns (copy these shapes exactly)
**P1 — bundle binding (Dashboard shell ONLY):** `custom.bundle` ← expr binding
```json
{"type": "expr",
"config": {"expression": "{session.custom.PrimeControls.query.refreshToken} + '|' + {session.custom.PrimeControls.query.startMs} + '|' + {session.custom.PrimeControls.query.endMs}"},
"transforms": [{"type": "script", "code": "\tq = self.session.custom.PrimeControls.query\n\treturn PrimeControls.alarms.getDashboardBundle(q.startMs, q.endMs, {'priorities': list(q.priorities), 'areas': list(q.areas), 'states': list(q.states), 'search': q.search})"}]}
```
**P2 — embed a sub-view with bundle + tab visibility:**
```json
{"meta": {"name": "tabOverview"}, "position": {"grow": 1},
"propConfig": {
"props.params.bundle": {"binding": {"type": "property", "config": {"path": "view.custom.bundle"}}},
"position.display": {"binding": {"type": "expr", "config": {"expression": "{session.custom.PrimeControls.ui.selectedTab} = 0"}}}},
"props": {"path": "PrimeControls/Tabs/Overview", "params": {}},
"type": "ia.display.view"}
```
**P3 — bidirectional session binding (FilterBar widgets):**
```json
{"binding": {"bidirectional": true, "type": "property",
"config": {"path": "session.custom.PrimeControls.query.search"}}}
```
**P4 — event scripts:** buttons/dropdowns fire `events.component.onActionPerformed`;
labels/icons/containers fire `events.dom.onClick` (Designer-verified shape). Action object
is identical for both: `{"config": {"script": "\t..."}, "scope": "G", "type": "script"}`.
```json
"events": {"dom": {"onClick": {"config": {"script": "\tsystem.perspective.openPopup('PC_HealthScore', 'PrimeControls/Popups/HealthScore', params = {'health': self.view.params.health}, showCloseIcon = True, draggable = True)"}, "scope": "G", "type": "script"}}}
```
**P5 — flex repeater from bundle slice:**
```json
{"type": "ia.display.flex-repeater",
"propConfig": {"props.instances": {"binding": {"type": "property", "config": {"path": "view.params.bundle.top_sources"}}}},
"props": {"path": "PrimeControls/Components/SourceRow", "direction": "column"}}
```
Repeater instance keys become the child view's params → **bundle array element keys ==
component view param names** (snake_case).
View param declaration: `"params": {"bundle": null}` + propConfig
`"params.bundle": {"paramDirection": "input", "persistent": true}`.
## Popups (IDs are constants)
| ID | View | Params |
|---|---|---|
| `PC_HealthScore` | `PrimeControls/Popups/HealthScore` | `{health: bundle.health}` |
| `PC_AlarmDetail` | `PrimeControls/Popups/AlarmDetail` | `{source, label}` |
| `PC_EventDetail` | `PrimeControls/Popups/EventDetail` | `{event: <journal row dict>}` |
| `PC_ShiftReport` | `PrimeControls/Popups/ShiftReport` | `{}` |
Close: `system.perspective.closePopup('<ID>')`.
## Style classes (foundation-owned; reference as `"style": {"classes": "PrimeControls/Card"}`)
`PrimeControls/Priority/{Diagnostic,Low,Medium,High,Critical}` (solid chip bg+fg) ·
`PrimeControls/PrioritySoft/{...same 5}` (tinted bg) ·
`PrimeControls/Grade/{A,B,C,D,F,NA}` · `PrimeControls/Card` · `PrimeControls/CardTitle` ·
`PrimeControls/Toolbar` · `PrimeControls/PageBg` · `PrimeControls/Chip/{Good,Bad,Flat}` ·
`PrimeControls/Tab/{Active,Inactive}` · `PrimeControls/State/{Active,Acked,Cleared}` ·
`PrimeControls/Text/{Big,Kpi,Muted}` · `PrimeControls/Empty`.
Priority scale: Diagnostic `#8A94A6` · Low `#5B9BD5` · Medium `#E5C453` · High `#E8883A` ·
Critical `#D64550`. Accent: `#3B7DD8`. Agents may ADD classes only under
`style-classes/PrimeControls/<TheirArea>/...`, never modify shared ones.
## Shared components (foundation-owned, under `views/PrimeControls/Components/`)
| View | Params (all input) |
|---|---|
| `Components/TabButton` | `title:str, index:int` (writes `ui.selectedTab` onClick; active style expr) |
| `Components/KpiCard` | `label:str, value:str, unit:str, delta:{...kpi delta}|null` (polarity already encoded in `delta.good`) |
| `Components/PriorityCard` | `priority_name:str, count:int, pct:float` |
| `Components/InsightRow` | `severity:int, icon:str, text:str, tab:str` |
| `Components/SourceRow` | `rank:int, source:str, label:str, count:int, pct:float` (click → focus+tab 2) |
| `Components/HeatmapCell` | `count:int, max:int` (bg alpha-scaled accent) |
| `Components/ScoreBar` | `key:str, label:str, score:float|null, weight:float, detail:str` |
| `Components/EmptyState` | `message:str, icon:str` |
| `Components/HealthGauge` | `health:{grade,score,subs}|null` (own dom.onClick opens PC_HealthScore with `{'health': ...}`) |
## View ownership (Phase 2/3)
A: `Shell/Header` + `Shell/FilterBar` (relocated out of `Dashboard/` — a view cannot
nest sub-views under another view's folder) · B: `Tabs/Overview` · C: `Tabs/Analysis` ·
D: `Tabs/BadActors` · E: `Tabs/Journal` · F: `Popups/HealthScore`+`Popups/ShiftReport` ·
G: `Popups/AlarmDetail`+`Popups/EventDetail` · H: `Components/HealthGauge` upgrade.
Integrator-only: `Dashboard/view.json`, session-props, page-config, shared Components,
root style classes, `script-python/*`, SimHarness.
## Verify loop (every agent, before declaring done)
```bash
python3 tools/lint_project.py
python3 tools/provision.py scan-projects
sleep 5; docker logs buildathon-ignition --since 1m 2>&1 | grep -iE "error|exception|traceback"
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8088/data/perspective/client/PrimeBAT
```
Schema uncertainty → read the harvested schema file; still unsure → minimal probe view in
`SimHarness/views/Scratch/Current` (coordinate via integrator), never guess deep chart JSON.